Si j'ai un entier 64 bits que j'interprète comme un tableau d'entiers 8 bits compressés avec 8 éléments. J'ai besoin de soustraire la constante 1
de chaque entier compressé tout en gérant le débordement sans que le résultat d'un élément n'affecte le résultat d'un autre élément.
J'ai ce code pour le moment et cela fonctionne mais j'ai besoin d'une solution qui effectue la soustraction de chaque entier 8 bits compressé en parallèle et n'effectue pas d'accès à la mémoire. Sur x86, je pourrais utiliser des instructions SIMD comme psubb
celle-ci soustrait les entiers 8 bits compressés en parallèle, mais la plate-forme pour laquelle je code ne prend pas en charge les instructions SIMD. (RISC-V dans ce cas).
J'essaie donc de faire SWAR (SIMD dans un registre) pour annuler manuellement la propagation de report entre les octets d'un uint64_t
, en faisant quelque chose d'équivalent à ceci:
uint64_t sub(uint64_t arg) {
uint8_t* packed = (uint8_t*) &arg;
for (size_t i = 0; i < sizeof(uint64_t); ++i) {
packed[i] -= 1;
}
return arg;
}
Je pense que vous pourriez le faire avec des opérateurs au niveau du bit mais je ne suis pas sûr. Je recherche une solution qui n'utilise pas les instructions SIMD. Je cherche une solution en C ou C ++ qui soit assez portable ou juste la théorie derrière pour que je puisse implémenter ma propre solution.
Réponses:
Si vous avez un processeur avec des instructions SIMD efficaces, SSE / MMX
paddb
(_mm_add_epi8
) est également viable. La réponse de Peter Cordes décrit également la syntaxe vectorielle GNU C (gcc / clang) et la sécurité pour UB à alias strict. J'encourage fortement à revoir également cette réponse.Le faire vous-même
uint64_t
est entièrement portable, mais nécessite toujours des précautions pour éviter les problèmes d'alignement et l'UB à alias strict lors de l'accès à unuint8_t
tableau avec unuint64_t*
. Vous avez laissé cette partie hors de question en commençant par vos données dans unuint64_t
déjà, mais pour GNU C unmay_alias
typedef résout le problème (voir la réponse de Peter pour cela oumemcpy
).Sinon, vous pourriez allouer / déclarer vos données en tant que
uint64_t
et y accéder viauint8_t*
quand vous voulez des octets individuels.unsigned char*
est autorisé à alias n'importe quoi afin de contourner le problème pour le cas spécifique des éléments 8 bits. (S'iluint8_t
existe, il est probablement sûr de supposer que c'est le casunsigned char
.)Notez qu'il s'agit d'un changement par rapport à un algorithme incorrect antérieur (voir l'historique des révisions).
Ceci est possible sans boucle pour une soustraction arbitraire, et devient plus efficace pour une constante connue comme
1
dans chaque octet. L'astuce principale consiste à empêcher l'exécution de chaque octet en définissant le bit haut, puis à corriger le résultat de la soustraction.Nous allons optimiser légèrement la technique de soustraction donnée ici . Ils définissent:
avec
H
défini comme0x8080808080808080U
(c'est-à-dire les MSB de chaque entier compressé). Pour une décrémentation,y
est0x0101010101010101U
.Nous savons que
y
tous ses MSB sont clairs, nous pouvons donc ignorer l'une des étapes du masque (c'esty & ~H
-à- dire la même quey
dans notre cas). Le calcul se déroule comme suit:x
sur 1, afin qu'un emprunt ne puisse pas se propager au-delà du MSB vers le composant suivant. Appelez cela l'entrée ajustée.0x01010101010101
de l'entrée corrigée. Cela n'entraîne pas d'emprunts entre composants grâce à l'étape 1. Appelez cela la sortie ajustée.L'opération peut s'écrire:
De préférence, cela est inséré par le compilateur (utilisez les directives du compilateur pour forcer cela), ou l'expression est écrite en ligne dans le cadre d'une autre fonction.
Testcases:
Détails des performances
Voici l'assemblage x86_64 pour une seule invocation de la fonction. Pour de meilleures performances, il doit être aligné dans l'espoir que les constantes puissent vivre dans un registre aussi longtemps que possible. Dans une boucle étroite où les constantes vivent dans un registre, le décrément réel prend cinq instructions: ou + pas + et + ajouter + xor après optimisation. Je ne vois pas d'alternatives qui pourraient battre l'optimisation du compilateur.
Avec certains tests IACA de l'extrait suivant:
nous pouvons montrer que sur une machine Skylake, effectuer la décrémentation, le xor et la comparaison + le saut peut être effectué à un peu moins de 5 cycles par itération:
(Bien sûr, sur x86-64, vous devez simplement charger ou
movq
dans un reg XMM pourpaddb
, il pourrait donc être plus intéressant de voir comment il se compile pour un ISA comme RISC-V.)la source
uint8_t
est autorisé à alias desuint8_t
données. Les appelants de votre fonction (qui doivent entrer desuint8_t
données dans auint64_t
) sont ceux qui doivent se soucier du strict alias! Donc, probablement, l'OP devrait simplement déclarer / allouer des tableaux caruint64_t
parce qu'ilchar*
est autorisé à alias n'importe quoi en ISO C ++, mais pas l'inverse.Pour RISC-V, vous utilisez probablement GCC / clang.
Fait amusant: GCC connaît certaines de ces astuces SWAR bithack (présentées dans d'autres réponses) et peut les utiliser pour vous lors de la compilation de code avec des vecteurs natifs GNU C pour des cibles sans instructions SIMD matérielles. (Mais clang pour RISC-V le déroulera naïvement en opérations scalaires, vous devez donc le faire vous-même si vous voulez de bonnes performances entre les compilateurs).
Un avantage de la syntaxe vectorielle native est que lors du ciblage d'une machine avec un SIMD matériel, il l'utilisera au lieu de vectoriser automatiquement votre bithack ou quelque chose d'horrible comme ça.
Il facilite l'écriture d'
vector -= scalar
opérations; la syntaxe Just Works, diffusant implicitement aka éclaboussant le scalaire pour vous.Notez également qu'une
uint64_t*
charge provenant d'unuint8_t array[]
UB à alias strict, soyez donc prudent. (Voir aussi Pourquoi le strlen de glibc doit-il être si compliqué pour s'exécuter rapidement? Re: rendre le bithacks SWAR strict-aliasing sûr en C pur). Vous voudrez peut-être quelque chose comme ça pour déclarer unuint64_t
que vous pouvez casté par pointeur pour accéder à d'autres objets, comme la façon dontchar*
fonctionne dans ISO C / C ++.utilisez-les pour obtenir des données uint8_t dans un uint64_t à utiliser avec d'autres réponses:
L'autre façon de faire des charges de sécurité aliasing est avec
memcpy
enuint64_t
, ce qui supprime également l'alignof(uint64_t
exigence d'alignement). Mais sur les ISA sans charges non alignées efficaces, gcc / clang ne s'alignent pas et ne s'optimisent pasmemcpy
lorsqu'ils ne peuvent pas prouver que le pointeur est aligné, ce qui serait désastreux pour les performances.TL: DR: votre meilleur pari est de déclarer vos données comme
uint64_t array[...]
ou de les allouer dynamiquement commeuint64_t
, ou de préférencealignas(16) uint64_t array[];
Cela garantit l'alignement sur au moins 8 octets, ou 16 si vous spécifiezalignas
.Puisque
uint8_t
c'est presque certainementunsigned char*
, il est sûr d'accéder aux octets d'uneuint64_t
viauint8_t*
(mais pas l'inverse pour un tableau uint8_t). Donc, pour ce cas spécial où le type d'élément étroit estunsigned char
, vous pouvez contourner le problème d'alias strict car ilchar
est spécial.Exemple de syntaxe vectorielle native GNU C:
Les vecteurs natifs GNU C sont toujours autorisés à alias avec leur type sous-jacent (par exemple,
int __attribute__((vector_size(16)))
peuvent alias en toute sécurité,int
mais pasfloat
ouuint8_t
ou autre chose.Pour RISC-V sans HW SIMD, vous pouvez utiliser
vector_size(8)
pour exprimer uniquement la granularité que vous pouvez utiliser efficacement et faire deux fois plus de vecteurs plus petits.Mais
vector_size(8)
compile très bêtement pour x86 avec GCC et clang: GCC utilise des bithacks SWAR dans les registres d'entiers GP, clang décompresse en éléments de 2 octets pour remplir un registre XMM de 16 octets puis recompresse. (MMX est tellement obsolète que GCC / clang ne prend même pas la peine de l'utiliser, du moins pas pour x86-64.)Mais avec
vector_size (16)
( Godbolt ) on obtient lemovdqa
/ attendupaddb
. (Avec un vecteur tout-en-un généré parpcmpeqd same,same
). Avec-march=skylake
nous obtenons toujours deux opérations XMM distinctes au lieu d'un YMM, donc malheureusement les compilateurs actuels ne "vectorisent" pas automatiquement les opérations vectorielles en vecteurs plus larges: /Pour AArch64, ce n'est pas si mal à utiliser
vector_size(8)
( Godbolt ); ARM / AArch64 peut fonctionner de manière native en blocs de 8 ou 16 octets avecd
ouq
registres.Donc, vous voulez probablement
vector_size(16)
compiler avec si vous voulez des performances portables sur x86, RISC-V, ARM / AArch64 et POWER . Cependant, certains autres ISA font SIMD dans des registres entiers 64 bits, comme MIPS MSA je pense.vector_size(8)
facilite la lecture de l'asm (un seul registre de données): Godbolt compiler explorerJe pense que c'est la même idée de base que les autres réponses sans boucle; empêchant le report puis fixant le résultat.
Ceci est 5 instructions ALU, pire que la réponse du haut je pense. Mais il semble que la latence du chemin critique ne soit que de 3 cycles, avec deux chaînes de 2 instructions menant chacune au XOR. La réponse de @Reinstate Monica - ζ - se compile en une chaîne dep à 4 cycles (pour x86). Le débit de boucle à 5 cycles est goulot d'étranglement en incluant également un
sub
sur le chemin critique, et la boucle fait goulot d'étranglement sur la latence.Cependant, cela est inutile avec clang. Il n'ajoute et ne stocke même pas dans le même ordre qu'il a chargé, donc il ne fait même pas de bons pipelining logiciels!
la source
Je voudrais souligner que le code que vous avez écrit est effectivement vectorisé une fois que vous commencez à traiter plus d'un uint64_t.
https://godbolt.org/z/J9DRzd
la source
__vector_loop(index, start, past, pad)
construction qu'une implémentation pourrait traiter commefor(index=start; index<past; index++)
[ce qui signifie que toute implémentation pourrait traiter du code en l'utilisant, simplement en définissant une macro], mais qui aurait une sémantique plus lâche pour inviter un compilateur à traiter des choses dans n'importe quelle taille de bloc de puissance de deux jusqu'àpad
, étendant le début vers le bas et la fin vers le haut s'ils ne sont pas déjà des multiples de la taille du bloc. Les effets secondaires au sein de chaque morceau ne seraient pas séquencés, et si unbreak
se produit dans la boucle, d'autres représentants ...restrict
est utile (et serait plus utile si la Norme reconnaissait un concept de "au moins potentiellement basé sur", puis définissait "sur la base de" et "au moins potentiellement sur la base de" simplement sans cas maladroits et impraticables) ma proposition permettrait également à un compilateur d'effectuer plus d'exécutions de la boucle que demandé - ce qui simplifierait grandement la vectorisation, mais pour lequel la norme ne prévoit rien.Vous pouvez vous assurer que la soustraction ne déborde pas, puis corriger le bit élevé:
la source
splat(0x01)
etsplat(0x80)
, au lieu de les obtenir les unes des autres avec un décalage. Même l'écrire de cette façon dans la source godbolt.org/z/6y9v-u ne tient pas le compilateur à la main pour créer un meilleur code; il fait juste une propagation constante.Je ne sais pas si c'est ce que vous voulez mais il fait les 8 soustractions en parallèle:
Explication: Le masque binaire commence par un 1 dans chacun des nombres à 8 bits. Nous le xor avec notre argument. Si nous avions un 1 à cet endroit, nous avons soustrait 1 et nous devons arrêter. Cela se fait en mettant le bit correspondant à 0 dans new_mask. Si nous avions un 0, nous le mettons à 1 et devons effectuer le report, donc le bit reste à 1 et nous décalons le masque vers la gauche. Vous feriez mieux de vérifier par vous-même si la génération du nouveau masque fonctionne comme prévu, je pense que oui, mais un deuxième avis ne serait pas mauvais.
PS: je ne sais pas vraiment si la vérification
mask_cp
non-nullité dans la boucle peut ralentir le programme. Sans cela, le code serait toujours correct (puisque le masque 0 ne fait rien) et il serait beaucoup plus facile pour le compilateur de faire le déroulement de la boucle.la source
for
ne fonctionnera pas en parallèle, êtes-vous confusfor_each
?Vous pouvez le faire avec des opérations au niveau du bit en utilisant ce qui précède, et il vous suffit de diviser votre entier en morceaux de 8 bits pour envoyer 8 fois dans cette fonction. La partie suivante est tirée de Comment diviser un nombre 64 bits en huit valeurs 8 bits? avec moi en ajoutant la fonction ci-dessus
C'est du C ou du C ++ valide quelle que soit la façon dont quelqu'un rencontre ce
la source
for_each(std::execution::par_unseq,...
au lieu de whilesJe ne vais pas essayer de trouver le code, mais pour une décrémentation de 1, vous pouvez décrémenter par le groupe de 8 1 et vérifier ensuite que les LSB des résultats ont "basculé". Tout LSB qui n'a pas basculé indique qu'un report s'est produit à partir des 8 bits adjacents. Il devrait être possible d'élaborer une séquence de AND / OR / XOR pour gérer cela, sans aucune branche.
la source
Concentrez le travail sur chaque octet entièrement seul, puis remettez-le à sa place.
la source