Lors de l'exĂ©cution de requĂȘtes dans ClickHouse, vous pouvez remarquer que dans le profileur, Ă l'un des premiers endroits, la fonction LZ_decompress_fast est souvent visible. Pourquoi cela se produit-il? Cette question est devenue la raison de toute l'Ă©tude sur le choix du meilleur algorithme de dĂ©compression. Ici, je publie l'Ă©tude entiĂšre, et la version courte peut ĂȘtre trouvĂ©e dans mon
rapport sur HighLoad ++ Siberia.
Les donnĂ©es ClickHouse sont stockĂ©es sous forme compressĂ©e. Et pendant l'exĂ©cution des requĂȘtes, ClickHouse essaie de ne rien faire - utiliser un minimum de ressources CPU. Il arrive que tous les calculs qui pourraient prendre un certain temps soient dĂ©jĂ bien optimisĂ©s, et la requĂȘte est bien Ă©crite par l'utilisateur. Reste alors Ă effectuer la libĂ©ration.

La question est, pourquoi la version LZ4 peut-elle ĂȘtre un goulot d'Ă©tranglement? Il semblerait que LZ4 soit un
algorithme trĂšs lĂ©ger : le taux de compression, selon les donnĂ©es, varie gĂ©nĂ©ralement de 1 Ă 3 Go / s par cĆur de processeur. C'est bien plus que la vitesse du sous-systĂšme de disque. De plus, nous utilisons tous les noyaux disponibles et l'expansion Ă©volue linĂ©airement sur tous les noyaux physiques.
Mais il y a deux points Ă garder Ă l'esprit. PremiĂšrement, les donnĂ©es compressĂ©es sont lues Ă partir du disque et le taux de compression est donnĂ© en quantitĂ© de donnĂ©es non compressĂ©es. Si le taux de compression est suffisamment grand, alors presque rien ne doit ĂȘtre lu sur les disques. Mais en mĂȘme temps, beaucoup de donnĂ©es compressĂ©es sont gĂ©nĂ©rĂ©es, et bien sĂ»r, cela affecte la consommation du processeur: la quantitĂ© de travail de compression de donnĂ©es dans le cas de LZ4 est presque proportionnelle au volume des donnĂ©es compressĂ©es lui-mĂȘme.
DeuxiĂšmement, la lecture des donnĂ©es Ă partir des disques peut ne pas ĂȘtre nĂ©cessaire du tout si les donnĂ©es sont dans le cache. Pour ce faire, vous pouvez compter sur le cache de pages ou utiliser votre propre cache. Dans une base de donnĂ©es de colonnes, l'utilisation du cache est plus efficace car toutes les colonnes n'y entrent pas, mais seulement celles frĂ©quemment utilisĂ©es. C'est pourquoi LZ4, en termes de charge CPU, est souvent un goulot d'Ă©tranglement.
D'oĂč deux autres questions. Si la compression des donnĂ©es "ralentit", alors peut-ĂȘtre qu'elles ne devraient pas du tout ĂȘtre compressĂ©es? Mais en pratique, cette hypothĂšse n'a pas de sens. RĂ©cemment, dans ClickHouse, il n'a Ă©tĂ© possible de configurer que deux options de compression de donnĂ©es - LZ4 et
Zstandard . La valeur par dĂ©faut est LZ4. En passant Ă Zstandard, vous pouvez rendre la compression plus forte et plus lente. Mais il Ă©tait impossible de dĂ©sactiver complĂštement la compression jusqu'Ă rĂ©cemment - LZ4 est considĂ©rĂ© comme un minimum raisonnable, qui peut toujours ĂȘtre utilisĂ©. C'est pourquoi j'aime vraiment le LZ4. :)
Mais récemment, un mystérieux inconnu est apparu dans le
chat de discussion anglophone ClickHouse, qui a dit qu'il avait un sous-systÚme de disque trÚs rapide (NVMe SSD) et que tout dépend de la compression - ce serait bien de pouvoir le désactiver. J'ai répondu qu'il n'y a pas une telle possibilité, mais c'est facile à ajouter. Quelques jours plus tard, nous avons reçu une
demande de pool , qui implémente la méthode de compression
none
. J'ai demandé les résultats - combien cela a aidé, la rapidité des demandes. La personne a déclaré que cette nouvelle fonctionnalité s'est avérée inutile dans la pratique, car les données sans compression ont commencé à prendre trop de place.
La deuxiÚme question qui se pose est: s'il y a un cache, pourquoi ne pas y stocker les données déjà non compressées? Ceci est autorisé - dans de nombreux cas, il sera possible de se débarrasser du besoin de décompression. Et dans ClickHouse, il existe un tel cache - un
cache de blocs Ă©tendus . Mais c'est dommage d'y consacrer beaucoup de RAM Ă cause de sa faible efficacitĂ©. Il ne se justifie que sur de petites requĂȘtes consĂ©cutives qui utilisent presque les mĂȘmes donnĂ©es.
ConsidĂ©ration gĂ©nĂ©rale: les donnĂ©es doivent ĂȘtre compressĂ©es, de prĂ©fĂ©rence toujours. Gravez-les toujours sur un disque compressĂ©. Transmettez sur le rĂ©seau Ă©galement avec compression. Ă mon avis, la compression par dĂ©faut doit ĂȘtre considĂ©rĂ©e comme justifiĂ©e mĂȘme lors du transfert vers un rĂ©seau de 10 gigabits sans surabonnement dans le centre de donnĂ©es, et le transfert de donnĂ©es sans compression entre les centres de donnĂ©es est gĂ©nĂ©ralement inacceptable.
Pourquoi LZ4?
Pourquoi LZ4 est-il utilisé? Est-il possible de choisir quelque chose de plus simple encore? En principe, c'est possible, c'est juste et utile. Mais regardons d'abord à quelle classe d'algorithmes appartient LZ4.
PremiÚrement, cela ne dépend pas du type de données. Par exemple, si vous savez à l'avance que vous disposerez d'un tableau d'entiers, vous pouvez utiliser l'une des nombreuses variantes de l'algorithme VarInt - il sera plus efficace sur le CPU. DeuxiÚmement, LZ4 ne dépend pas trop des hypothÚses requises sur le modÚle de données. Supposons que vous ayez une série chronologique ordonnée de lectures de capteur - un tableau avec des nombres de type float. Ensuite, vous pouvez calculer les deltas, puis compresser davantage, ce qui sera plus efficace en termes de taux de compression.
Autrement dit, LZ4 peut ĂȘtre utilisĂ© sans problĂšme pour tous les tableaux d'octets - pour tous les fichiers. Bien sĂ»r, il a sa propre spĂ©cialisation (plus de dĂ©tails ci-dessous), et dans certains cas, son utilisation n'a pas de sens. Mais si vous l'appelez un algorithme Ă usage gĂ©nĂ©ral, ce sera une petite erreur. Et notez que, grĂące au dispositif interne, LZ4 implĂ©mente automatiquement l'algorithme
RLE comme cas particulier.
Autre question: LZ4 est-il l'algorithme le plus optimal de cette classe pour la combinaison de la vitesse et de la force de compression? De tels algorithmes sont appelĂ©s pareto frontier - cela signifie qu'aucun autre algorithme n'est strictement meilleur dans un indicateur et pas pire dans d'autres (et mĂȘme sur une grande variĂ©tĂ© de jeux de donnĂ©es). Il existe des algorithmes plus rapides, mais qui donnent un taux de compression plus faible, et d'autres qui compressent plus, mais en mĂȘme temps, compressent ou dĂ©compressent plus lentement.
En fait, le LZ4 n'est pas une frontiÚre pareto. Il existe des options légÚrement meilleures. Par exemple, c'est
LZTURBO d'un certain
powturbo . Il n'y a aucun doute sur la fiabilitĂ© des rĂ©sultats grĂące Ă la communautĂ© sur encode.ru (le plus grand et approximativement le seul forum de compression de donnĂ©es). Mais le dĂ©veloppeur ne distribue pas le code source ou les binaires, mais ne les donne qu'Ă un cercle restreint de personnes pour des tests ou pour beaucoup d'argent (comme personne n'a payĂ© jusqu'Ă prĂ©sent). Il convient Ă©galement de prĂȘter attention au
lézard (anciennement LZ5) et à la
densité . Ils peuvent fonctionner un peu mieux que LZ4 lors du choix d'un niveau de compression.
Faites Ă©galement attention Ă
LZSSE - une chose extrĂȘmement intĂ©ressante. Cependant, il vaut mieux le regarder aprĂšs avoir lu cet article.
Comment fonctionne LZ4?
Voyons comment fonctionne LZ4 en général. C'est l'une des implémentations de l'algorithme LZ77: L et Z indiquent les noms des auteurs (Lempel et Ziv), et 77 - en 1977, lorsque l'algorithme a été publié. Il a de nombreuses autres implémentations: QuickLZ, FastLZ, BriefLZ, LZF, LZO, ainsi que gzip et zip lors de l'utilisation de faibles niveaux de compression.
Un bloc de données compressé à l'aide de LZ4 contient une séquence d'enregistrements (commandes, instructions) de deux types:
- Littéral: "prenez les N octets suivants tels quels et copiez-les dans le résultat."
- Match (match): "prendre N octets qui ont déjà été décompressés par le décalage de décalage par rapport à la position actuelle."
Un exemple. Avant la compression:
Hello world Hello
AprĂšs compression:
literals 12 "Hello world " match 5 12
Si nous prenons un bloc compressé et le parcourons avec le curseur, en exécutant ces commandes, nous obtiendrons les données initiales non compressées en conséquence.
Nous avons examiné en gros comment les données sont décompressées. Le point est également clair: pour effectuer la compression, l'algorithme code des séquences d'octets répétitives à l'aide de correspondances.
Clair et quelques propriétés. Cet algorithme est orienté octet - il ne dissÚque pas les octets individuels, mais les copie uniquement dans son intégralité. C'est là que réside, par exemple, la différence avec le codage entropique. Par exemple,
zstd est une composition de LZ77 et de codage entropique.
Notez que la taille du bloc compressé n'est pas choisie trop grande pour ne pas dépenser beaucoup de RAM lors du déchargement; afin de ne pas ralentir l'accÚs aléatoire dans un fichier compressé (composé de nombreux blocs compressés); et parfois pour que le bloc tienne dans un cache CPU. Par exemple, vous pouvez choisir 64 Ko - de sorte que les tampons pour les données compressées et non compressées tiendront dans le cache L2 et la moitié restera.
Si nous devons compresser un fichier plus volumineux, nous allons simplement concatĂ©ner les blocs compressĂ©s. En mĂȘme temps, Ă cĂŽtĂ© de chaque bloc compressĂ©, il est pratique de placer des donnĂ©es supplĂ©mentaires - tailles, somme de contrĂŽle.
Le dĂ©calage maximum pour la correspondance est limitĂ©, en LZ4 - 64 kilo-octets. Cette valeur est appelĂ©e une fenĂȘtre coulissante. En effet, cela signifie qu'au fur et Ă mesure que le curseur avance, les correspondances peuvent se trouver dans une fenĂȘtre de 64 kilo-octets par rapport au curseur, qui se dĂ©place avec le curseur.
Voyons maintenant comment compresser les donnĂ©es - en d'autres termes, comment trouver les sĂ©quences correspondantes dans un fichier. Bien sĂ»r, vous pouvez utiliser le suffixe trie (idĂ©al si vous en avez entendu parler). Il existe des options dans lesquelles la sĂ©quence de correspondance la plus longue est garantie d'ĂȘtre parmi les octets prĂ©cĂ©dents dans le processus de compression. C'est ce qu'on appelle l'analyse optimale et donne un taux de compression
presque meilleur pour un format de bloc compressé fixe. Mais il existe des options plus efficaces - lorsque nous trouvons une correspondance suffisamment bonne dans les données, mais pas nécessairement la plus longue. Le moyen le plus efficace de le trouver est d'utiliser une table de hachage.
Pour ce faire, nous parcourons le bloc de donnĂ©es source avec le curseur et prenons quelques octets aprĂšs le curseur. Par exemple, 4 octets. Les hacher et mettre dans la table de hachage le dĂ©calage depuis le dĂ©but du bloc - oĂč ces 4 octets se sont rencontrĂ©s. La valeur 4 est appelĂ©e min-match - Ă l'aide d'une telle table de hachage, nous pouvons trouver des correspondances d'au moins 4 octets.
Si nous avons regardĂ© la table de hachage, et qu'il y a dĂ©jĂ un enregistrement lĂ -bas, et si l'offset ne dĂ©passe pas la fenĂȘtre glissante, alors nous vĂ©rifions combien d'octets supplĂ©mentaires correspondent aprĂšs ces quatre octets. Il y a peut-ĂȘtre beaucoup plus qui coĂŻncident. Il est Ă©galement possible qu'une collision se soit produite dans la table de hachage et que rien ne corresponde. C'est normal - vous pouvez simplement remplacer la valeur de la table de hachage par une nouvelle. Les collisions dans la table de hachage entraĂźneront simplement un taux de compression plus faible car il y a moins de correspondances. Soit dit en passant, ce type de table de hachage (de taille fixe et sans rĂ©solution de collision) est appelĂ© table de cache, une table de cache. Cela est Ă©galement logique - en cas de collision, la table de cache oublie simplement l'ancien enregistrement.
La tùche du lecteur attentif. Soit les données un tableau de nombres comme UInt32 en petit format endian, qui fait partie d'une séquence de nombres naturels: 0, 1, 2 ... Expliquez pourquoi lorsque vous utilisez LZ4 ces données ne sont pas compressées (la quantité de données compressées n'est pas inférieure à la quantité de données non compressées).
Comment accélérer les choses
Donc, je veux accélérer le déchargement de LZ4. Voyons à quoi ressemble le cycle de déchargement. Voici la boucle en pseudocode:
tandis que (...)
{
lire (input_pos, literal_length, match_length);
copie (output_pos, input_pos, literal_length);
output_pos + = literal_length;
lecture (input_pos, match_offset);
copy (output_pos, output_pos - match_offset,
match_length);
output_pos + = match_length;
}
Le format LZ4 est conçu pour que les littĂ©raux et les correspondances alternent dans un fichier compressĂ©. Et Ă©videmment, le littĂ©ral vient toujours en premier (car depuis le tout dĂ©but, le match n'a nulle part oĂč aller). Par consĂ©quent, leurs longueurs sont codĂ©es ensemble.
En fait, tout est un peu plus compliquĂ©. Un octet est lu dans le fichier et deux quartets sont extraits de celui-ci, dans lesquels les nombres de 0 Ă 15. sont codĂ©s. Si le nombre correspondant n'est pas Ă©gal Ă 15, alors il est considĂ©rĂ© comme la longueur du littĂ©ral et de la correspondance, respectivement. Et si c'est 15, alors la longueur est plus longue et elle est codĂ©e dans les octets suivants. Ensuite, l'octet suivant est lu et sa valeur est ajoutĂ©e Ă la longueur. De plus, s'il est Ă©gal Ă 255, alors nous continuons - nous lisons l'octet suivant de la mĂȘme maniĂšre.
Notez que le taux de compression maximum pour le format LZ4 n'atteint pas 255. Et la deuxiÚme observation (inutile): si vos données sont trÚs redondantes, alors l'utilisation de LZ4 augmentera le taux de compression doubler.
Lorsque nous lisons la longueur du littéral (puis aussi la longueur de la correspondance et le décalage de la correspondance), pour desserrer il suffit de copier simplement deux morceaux de mémoire.
Comment copier un morceau de mémoire
Il semblerait que vous pouvez utiliser la fonction
memcpy
, qui est juste conçue pour copier des morceaux de mémoire. Mais ce n'est pas optimal et toujours incorrect.
Pourquoi l'utilisation de la fonction memcpy n'est-elle pas optimale? Parce qu'elle:
- généralement situé dans la bibliothÚque libc (et la bibliothÚque libc est généralement liée dynamiquement, et l'appel memcpy ira indirectement, via PLT),
- pas en ligne avec l'argument taille inconnu au moment de la compilation,
- fait beaucoup d'efforts pour traiter correctement les «queues» d'un fragment de mémoire qui ne sont pas multiples de la taille d'un mot machine ou d'un registre.
Le dernier point est le plus important. Supposons que nous ayons demandé à la fonction memcpy de copier exactement 5 octets. Il serait trÚs bien de copier 8 octets à la fois, en utilisant deux instructions movq pour cela.
Hello world Hello wo ...
^^^^^ ^^^ - src
^^^^^ ^^^ - dst
Mais ensuite, nous allons copier trois octets supplémentaires - c'est-à -dire que nous allons écrire à l'étranger le tampon transféré. La fonction
memcpy
n'a pas le droit de le faire - en effet, parce que nous allons Ă©craser certaines donnĂ©es dans notre programme, il y aura un «passage» de la mĂ©moire. Et si nous avons Ă©crit Ă une adresse non alignĂ©e, ces octets supplĂ©mentaires peuvent ĂȘtre situĂ©s sur une page de mĂ©moire virtuelle non allouĂ©e ou sur une page sans accĂšs en Ă©criture. Ensuite, nous obtenons un dĂ©faut de segmentation (c'est bien).
Mais dans notre cas, nous pouvons presque toujours Ă©crire des octets supplĂ©mentaires. Nous pouvons lire des octets supplĂ©mentaires dans le tampon d'entrĂ©e tant que les octets supplĂ©mentaires s'y trouvent entiĂšrement. Dans les mĂȘmes conditions, nous pouvons Ă©crire des octets supplĂ©mentaires dans le tampon de sortie - car Ă la prochaine itĂ©ration, nous les remplacerons de toute façon.
Cette optimisation est déjà dans l'implémentation LZ4 d'origine:
inline void copy8 (UInt8 * dst, const UInt8 * src)
{
memcpy (dst, src, 8); /// En fait, memcpy n'est pas appelé ici.
}
inline void wildCopy8 (UInt8 * dst, const UInt8 * src, UInt8 * dst_end)
{
faire
{
copy8 (dst, src);
dst + = 8;
src + = 8;
} while (dst <dst_end);
}
Pour profiter de cette optimisation, il suffit de vĂ©rifier que l'on est assez loin de la frontiĂšre du buffer. Cela devrait ĂȘtre gratuit, car nous vĂ©rifions dĂ©jĂ que les limites de tampon sont dĂ©passĂ©es. Et le traitement des derniers octets - la "queue" des donnĂ©es - peut se faire aprĂšs la boucle principale.
Cependant, il y a encore quelques subtilités. Il y a deux exemplaires dans le cycle - littéral et match. Mais lorsque vous utilisez la fonction LZ4_decompress_fast (au lieu de LZ4_decompress_safe), la vérification est effectuée une fois - lorsque nous devons copier le littéral. Lors de la copie d'une correspondance, la vérification n'est pas effectuée, mais dans la
spécification du format LZ4, il existe des conditions qui permettent de l'éviter:
Les 5 derniers octets sont toujours des littéraux
La derniĂšre correspondance doit commencer au moins 12 octets avant la fin du bloc.
Par consĂ©quent, un bloc de moins de 13 octets ne peut pas ĂȘtre compressĂ©.
Des donnĂ©es d'entrĂ©e spĂ©cialement sĂ©lectionnĂ©es peuvent provoquer un lecteur de mĂ©moire. Si vous utilisez la fonction LZ4_decompress_fast, vous avez besoin d'une protection contre les donnĂ©es incorrectes. Les donnĂ©es compressĂ©es doivent ĂȘtre au moins une somme de contrĂŽle. Et si vous avez besoin d'une protection contre un attaquant, utilisez la fonction LZ4_decompress_safe. Autres options: prenez une fonction de hachage cryptographique comme somme de contrĂŽle, mais cela tuera presque certainement toutes les performances; soit allouer plus de mĂ©moire aux tampons; allouez de la mĂ©moire aux tampons avec un appel distinct Ă mmap et crĂ©ez une page de garde.
Quand je vois un code qui copie des données de 8 octets, je demande immédiatement - pourquoi exactement 8 octets? Vous pouvez copier 16 octets à l'aide des registres SSE:
inline void copy16 (UInt8 * dst, const UInt8 * src)
{
#if __SSE2__
_mm_storeu_si128 (reinterpret_cast <__ m128i *> (dst),
_mm_loadu_si128 (reinterpret_cast <const __m128i *> (src)));
#else
memcpy (dst, src, 16);
#endif
}
inline void wildCopy16 (UInt8 * dst, const UInt8 * src, UInt8 * dst_end)
{
faire
{
copy16 (dst, src);
dst + = 16;
src + = 16;
} while (dst <dst_end);
}
La copie de 32 octets pour AVX et de 64 octets pour AVX-512 fonctionne de maniÚre similaire. De plus, vous pouvez étendre le cycle plusieurs fois. Si vous avez déjà regardé comment
memcpy
, c'est exactement l'approche. (Soit dit en passant, le compilateur dans ce cas ne développera ni ne vectorisera la boucle: cela nécessitera l'insertion de vérifications lourdes.)
Pourquoi cela n'est-il pas fait dans l'implĂ©mentation LZ4 d'origine? PremiĂšrement, il n'est pas Ă©vident que ce soit meilleur ou pire. Le rĂ©sultat dĂ©pend de la taille des fragments qui doivent ĂȘtre copiĂ©s. Soudain, ils sont tous courts et le travail supplĂ©mentaire sera inutile? Et deuxiĂšmement, il dĂ©truit ces conditions au format LZ4 qui vous permettent d'Ă©viter les brunchs inutiles dans la boucle intĂ©rieure.
Néanmoins, nous garderons cette option à l'esprit pour l'instant.
Copie délicate
Retour à la question - est-il toujours possible de copier des données de cette façon? Supposons que nous ayons besoin de copier une correspondance, c'est-à -dire de copier un morceau de mémoire du tampon de sortie qui est à un certain décalage derriÚre le curseur à la position de ce curseur.
Imaginez un cas simple - vous devez copier 5 octets Ă l'offset 12:
Hello world ...........
^^^^^ - src
^^^^^ - dst
Hello world Hello wo ...
^^^^^ - src
^^^^^ - dst
Mais il y a un cas plus compliqué - quand nous devons copier un morceau de mémoire dont la longueur est supérieure au décalage. C'est-à -dire qu'il indique partiellement des données qui n'ont pas encore été écrites dans le tampon de sortie.
Copiez 10 octets Ă l'offset 3:
abc .............
^^^^^^^^^^ - src
^^^^^^^^^^ - dst
abc abcabcabca ...
^^^^^^^^^^ - src
^^^^^^^^^^ - dst
Dans le processus de compression, nous avons toutes les donnĂ©es, et une telle correspondance peut trĂšs bien ĂȘtre trouvĂ©e. La fonction
memcpy
ne convient pas pour la copier: elle ne prend pas en charge le cas oĂč les plages de fragments de mĂ©moire se croisent. Soit dit en passant, la fonction
memmove
ne convient pas non plus, car le fragment de mĂ©moire d'oĂč obtenir les donnĂ©es n'est pas encore complĂštement initialisĂ©. Vous devez copier comme si nous copions par octet.
op [0] = correspond Ă [0];
op [1] = correspond Ă [1];
op [2] = correspond Ă [2];
op [3] = correspond Ă [3];
...
Voici comment cela fonctionne:
a bc a ............
^ - src
^ - dst
a b ca b ...........
^ - src
^ - dst
ab c ab c ..........
^ - src
^ - dst
abc a bc a .........
^ - src
^ - dst
abca b ca b ........
^ - src
^ - dst
Autrement dit, nous devons créer une séquence répétitive. Dans l'implémentation LZ4 d'origine, un code étonnamment incompréhensible a été écrit pour cela:
const unsigned dec32table [] = {0, 1, 2, 1, 4, 4, 4, 4};
const int dec64table [] = {0, 0, 0, -1, 0, 1, 2, 3};
const int dec64 = dec64table [offset];
op [0] = correspond Ă [0];
op [1] = correspond Ă [1];
op [2] = correspond Ă [2];
op [3] = correspond Ă [3];
match + = dec32table [offset];
memcpy (op + 4, match, 4);
match - = dec64;
Nous copions les 4 premiers octets octet par bit, décalons d'un certain nombre magique, copions les 4 octets suivants dans leur ensemble, décalons le pointeur pour qu'il corresponde à un autre nombre magique. L'auteur du code (
Jan Collet ), pour une raison ridicule, a oubliĂ© de laisser un commentaire sur ce que cela signifie. De plus, les noms de variables prĂȘtent Ă confusion. Les deux s'appellent dec ... table, mais nous ajoutons l'un d'eux et soustrayons l'autre. De plus, un autre n'est pas signĂ© et l'autre est int. Cependant, il vaut la peine de rendre hommage: tout rĂ©cemment, l'auteur a amĂ©liorĂ© cette place dans le code.
Voici comment cela fonctionne réellement. Copiez les 4 premiers octets:
abc abca .........
^^^^ - src
^^^^ - dst
Vous pouvez maintenant copier 4 octets Ă la fois:
abcabca bcab .....
^^^^ - src
^^^^ - dst
Vous pouvez continuer comme d'habitude en copiant 8 octets Ă la fois:
abcabcabcab cabcabca .....
^^^^^^^^ - src
^^^^^^^^ - dst
, â . :
inline void copyOverlap8(UInt8 * op, const UInt8 *& match, const size_t offset)
{
/// 4 % n.
/// Or if 4 % n is zero, we use n.
/// It gives equivalent result, but is better CPU friendly for unknown reason.
static constexpr int shift1[] = { 0, 1, 2, 1, 4, 4, 4, 4 };
/// 8 % n - 4 % n
static constexpr int shift2[] = { 0, 0, 0, 1, 0, -1, -2, -3 };
op[0] = match[0];
op[1] = match[1];
op[2] = match[2];
op[3] = match[3];
match += shift1[offset];
memcpy(op + 4, match, 4);
match += shift2[offset];
}
, , . , , â 16 .
« » , (
offset < 16
,
offset < 8
). () 16- :
inline void copyOverlap16(UInt8 * op, const UInt8 *& match, const size_t offset)
{
/// 4 % n.
static constexpr int shift1[]
= { 0, 1, 2, 1, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4 };
/// 8 % n - 4 % n
static constexpr int shift2[]
= { 0, 0, 0, 1, 0, -1, -2, -3, -4, 4, 4, 4, 4, 4, 4, 4 };
/// 16 % n - 8 % n
static constexpr int shift3[]
= { 0, 0, 0, -1, 0, -2, 2, 1, 8, -1, -2, -3, -4, -5, -6, -7 };
op[0] = match[0];
op[1] = match[1];
op[2] = match[2];
op[3] = match[3];
match += shift1[offset];
memcpy(op + 4, match, 4);
match += shift2[offset];
memcpy(op + 8, match, 8);
match += shift3[offset];
}
? , SIMD-, 16 , ( 1 15). , , .
â
pshufb
( packed shuffle bytes) SSSE3 ( S). 16- . . â «»: 0 15 â , . , 127 â .
Voici un exemple:
xmm0: abc.............
xmm1: 0120120120120120
pshufb %xmm1, %xmm0
xmm0: abcabcabcabcabca
â ! :
inline void copyOverlap16Shuffle(UInt8 * op, const UInt8 *& match, const size_t offset)
{
#ifdef __SSSE3__
static constexpr UInt8 __attribute__((__aligned__(16))) masks[] =
{
0, 1, 2, 1, 4, 1, 4, 2, 8, 7, 6, 5, 4, 3, 2, 1, /* offset = 0, not used as mask, but for shift amount instead */
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, /* offset = 1 */
0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1,
0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2, 0,
0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3,
0, 1, 2, 3, 4, 0, 1, 2, 3, 4, 0, 1, 2, 3, 4, 0,
0, 1, 2, 3, 4, 5, 0, 1, 2, 3, 4, 5, 0, 1, 2, 3,
0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1,
0, 1, 2, 3, 4, 5, 6, 7, 0, 1, 2, 3, 4, 5, 6, 7,
0, 1, 2, 3, 4, 5, 6, 7, 8, 0, 1, 2, 3, 4, 5, 6,
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5,
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 0, 1, 2, 3, 4,
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 0, 1, 2, 3,
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 0, 1, 2,
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 0, 1,
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 0,
};
_mm_storeu_si128(reinterpret_cast<__m128i *>(op),
_mm_shuffle_epi8(
_mm_loadu_si128(reinterpret_cast<const __m128i *>(match)),
_mm_load_si128(reinterpret_cast<const __m128i *>(masks) + offset)));
match += masks[offset];
#else
copyOverlap16(op, match, offset);
#endif
}
_mm_shuffle_epi8
â
intrinsic ,
pshufb
.
, ? SSSE3 â , 2006 . AVX2 , 32 , 16- . packed shuffle bytes, vector permute bytes â , . AVX-512 VBMI , 64 , . ARM NEON â vtbl (vector table lookup), 8 .
,
pshufb
64- MMX-, 8 . . , , 16 ( ).
Highload++ Siberia , 8 ( ) â !
if
, , 16 . ?
, . , , , . , .
, . , , , 65 536 . 65 536 . , , 65 551 . , , 96 128 â . , «» mmap ( madvice). - page faults. , .
?
, , :
- 16 8.
- shuffle-
offset < 16
. - if.
.
Exemple 1:
Xeon E2650v2, ., AppVersion.
reference: 1.67 GB/sec.
16 bytes, shuffle: 2.94 GB/sec ( 76% ).
Exemple 2:
Xeon E2650v2, ., ShowsSumPosition.
reference: 2.30 GB/sec.
16 bytes, shuffle: 1.91 GB/sec ( 20% ).
, . , . - , . , . â 16 . : , , .
, C++ : 8- 16- ; shuffle-.
template <size_t copy_amount, bool use_shuffle>
void NO_INLINE decompressImpl(
const char * const source,
char * const dest,
size_t dest_size)
, shuffle . , :
sudo echo 'performance' | tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor
kill -STOP $(pidof firefox) $(pidof chromium)
«» (c Xeon E5645), , . , , . , shuffle-, , 16- .
:
sudo kill -STOP $(pidof python) $(pidof perl) $(pgrep -u skynet) $(pidof cqudp-client)
. thermal throttling power capping.
, , . , , . . , , . : ClickHouse , , . , ( â ?). .
, , .
« » . , , , .
, . . - . â ClickHouse 64 . (
.)
, « », , . , , , - . . , , . .
, , . «» , . , . Thompson Sampling.
, , . â : , . . , . , C++. â , - , ; .
? , . . -, , . -, , , «» .
, , Thompson Sampling â ( , ). , , - , , . , .
, «» . , , «», . â
. , , .
, , , , «»:
/// For better convergence, we don't use proper estimate of stddev.
/// We want to eventually separate between two algorithms even in case
/// when there is no statistical significant difference between them.
double sigma() const
{
return mean() / sqrt(adjustedCount());
}
double sample(pcg64 & rng) const
{
...
return std::normal_distribution<>(mean(), sigma())(rng);
}
, â memory latencies.
, , â LZ4 .
, :
â reference (baseline): LZ4 ;
â variant 0: 8 , shuffle;
â variant 1: 8 , shuffle;
â variant 2: 16 , shuffle;
â variant 3: 16 , shuffle;
â «» , .
CPU
CPU, , . , CPU ?
ClickHouse , 256 100 ( 256 ). , CPU , . CPU:
â IntelÂź XeonÂź CPU E5-2650 v2 @ 2.60GHz
â IntelÂź XeonÂź CPU E5-2660 v4 @ 2.00GHz
â IntelÂź XeonÂź CPU E5-2660 0 @ 2.20GHz
â IntelÂź XeonÂź CPU E5645 @ 2.40GHz
â Intel Xeon E312xx (Sandy Bridge)
â AMD Opteron(TM) Processor 6274
â AMD Opteron(tm) Processor 6380
â IntelÂź XeonÂź CPU E5-2683 v4 @ 2.10GHz
â IntelÂź XeonÂź CPU E5530 @ 2.40GHz
â IntelÂź XeonÂź CPU E5440 @ 2.83GHz
â IntelÂź XeonÂź CPU E5-2667 v2 @ 3.30GHz
â , R&D:
â AMD EPYC 7351 16-Core Processor â AMD.
â Cavium ThunderX2 â x86, AArch64. SIMD- . 224 56 .
13 , 256 6 (reference, 0, 1, 2, 3, adaptive), 10 , . 199 680 , .
, CPU . : LZ4 ( â ). , Cavium . ClickHouse, «» Xeon E5-2650 v2 , , ClickHouse x86.
ââcpuââââââââââââââââââââŹâârefââŹâadaptââŹââmaxââŹâbestââŹâadapt_boostââŹâmax_boostââŹâadapt_over_maxââ
â E5-2667 v2 @ 3.30GHz â 2.81 â 3.19 â 3.15 â 3 â 1.14 â 1.12 â 1.01 â
â E5-2650 v2 @ 2.60GHz â 2.5 â 2.84 â 2.81 â 3 â 1.14 â 1.12 â 1.01 â
â E5-2683 v4 @ 2.10GHz â 2.26 â 2.63 â 2.59 â 3 â 1.16 â 1.15 â 1.02 â
â E5-2660 v4 @ 2.00GHz â 2.15 â 2.49 â 2.46 â 3 â 1.16 â 1.14 â 1.01 â
â AMD EPYC 7351 â 2.03 â 2.44 â 2.35 â 3 â 1.20 â 1.16 â 1.04 â
â E5-2660 0 @ 2.20GHz â 2.13 â 2.39 â 2.37 â 3 â 1.12 â 1.11 â 1.01 â
â E312xx (Sandy Bridge) â 1.97 â 2.2 â 2.18 â 3 â 1.12 â 1.11 â 1.01 â
â E5530 @ 2.40GHz â 1.65 â 1.93 â 1.94 â 3 â 1.17 â 1.18 â 0.99 â
â E5645 @ 2.40GHz â 1.65 â 1.92 â 1.94 â 3 â 1.16 â 1.18 â 0.99 â
â AMD Opteron 6380 â 1.47 â 1.58 â 1.56 â 1 â 1.07 â 1.06 â 1.01 â
â AMD Opteron 6274 â 1.15 â 1.35 â 1.35 â 1 â 1.17 â 1.17 â 1 â
â E5440 @ 2.83GHz â 1.35 â 1.33 â 1.42 â 1 â 0.99 â 1.05 â 0.94 â
â Cavium ThunderX2 â 0.84 â 0.87 â 0.87 â 0 â 1.04 â 1.04 â 1 â
âââââââââââââââââââââââââŽâââââââŽââââââââŽâââââââŽâââââââŽââââââââââââââŽââââââââââââŽâââââââââââââââââ
ref, adapt, max â (, ). best â , 0 3. adapt_boost â baseline. max_boost â baseline. adapt_over_max â .
, x86 12â20%. ARM 4%, , . , «» Intel.
. , LZ4 12â20%, . . , .
, , «» , ZStandard level 1 LZ4: IO .
â , . , .
: . LZ4 , Lizard, Density LZSSE , . , LZ4 LZSSE ClickHouse.
LZ4 : . : , . . , inc- dec-
. , 12â15% 32 , 16, . 32 â ,
.
, , page cache userspace ( mmap, O_DIRECT userspace page cache â ), - ( CityHash128 CRC32-C, HighwayHash, FARSH XXH3). , .
, master, .
HighLoad++ Siberia,
.