Créer un utilitaire de compression basé sur le code de Huffman en Haskell
(lazamar.github.io)- L’article montre, avec environ 150 lignes de Haskell, comment implémenter un compresseur Huffman capable d’encoder et de décoder des fichiers binaires arbitraires en mémoire constante.
- Le code de Huffman attribue des séquences de bits courtes aux valeurs fréquentes et s’appuie sur la propriété de prefix-free code pour permettre au décodeur d’interpréter les bits sans ambiguïté.
- L’implémentation construit un arbre à partir de
FreqMap,HTreeetCodeMap, puis génère progressivement le résultat grâce à l’évaluation paresseuse deconcatMapet d’un décodeur récursif. - Les fichiers binaires sont manipulés via
Data.ByteString.Char8, qui traite les octets comme desChar; la sortie compressée commence par la table des fréquences, puis stocke le flux de bits avec un remplissage à l’octet. - Lors des tests, War and Peace passe de 3.2M à 1.9M, et le binaire
ghcupde 106M descend à 84M, avec un maximum de mémoire résidente observé inférieur à 300KB.
Idée de base de la compression par code de Huffman
- L’objectif est d’implémenter un utilitaire de compression de données en environ 150 lignes de Haskell à l’aide du Huffman coding.
- Le code complet est publié dans un dépôt GitHub.
- Le code de Huffman attribue une séquence de bits unique à chaque caractère ou valeur.
- Les valeurs fréquentes utilisent des séquences de bits courtes.
- Les valeurs rares utilisent des séquences de bits longues.
- La compression vient du fait que les valeurs courantes sont représentées avec moins de bits que dans leur représentation d’origine.
- Dans l’exemple
aaab, si l’on posea = 1etb = 0, on obtient1110.- C’est un exemple où une chaîne nécessitant 4 octets en UTF-8 est représentée sur un demi-octet.
Prefix-free code et arbre de Huffman
- Pour que le décodage soit non ambigu, aucun mot de code ne doit être le préfixe d’un autre.
- Par exemple, dans
aaabc, si l’on assignea = 1,b = 10,c = 01, alors101peut être ambigu entreacetba.
- Par exemple, dans
- Un prefix-free code peut être représenté par un arbre binaire complet.
- Toutes les valeurs sont placées sur les feuilles.
- L’arête de gauche est étiquetée
1, celle de droite0. - Le chemin de la racine jusqu’à une feuille devient le mot de code de cette valeur.
- L’arbre de Huffman est construit en regroupant depuis le bas les valeurs les moins fréquentes.
- On crée un nœud pour chaque caractère avec son poids, c’est-à-dire son nombre d’occurrences.
- On regroupe en arbre les deux nœuds de plus petit poids.
- Le poids du nouvel arbre est la somme des poids des deux nœuds.
- On répète jusqu’à ce qu’il ne reste plus qu’un seul arbre.
- Ce processus rapproche de la racine les valeurs les plus fréquentes, qui obtiennent ainsi des mots de code plus courts.
Structure de l’encodeur Haskell
- Les principaux types de l’implémentation sont
Bit,Code,FreqMap,CodeMap,WeightetHTree.BitvautOneouZero.Codeest[Bit].FreqMapest unMap Char Intqui stocke le nombre d’occurrences par caractère.CodeMapest unMap Char Codequi stocke le mot de code de chaque caractère.HTreeest soitLeaf Weight Char, soitFork Weight HTree HTree.
HTreeest rendu comparable sur la base du poids, ce qui simplifie le tri et l’insertion pendant la construction de l’arbre.countFrequencycalcule le nombre d’occurrences de chaque caractère dans une chaîne.buildTreetransforme leFreqMapen liste de feuilles, la trie, puis fusionne à répétition les deux plus petits nœuds pour construire l’arbre de Huffman.buildCodesparcourt l’arbre en ajoutantOneà gauche etZeroà droite afin de générer le mot de code de chaque caractère.encode :: FreqMap -> String -> [Bit]construit l’arbre et la table de codes à partir duFreqMap, puis remplace chaque caractère de la chaîne d’entrée par son mot de code pour produire une liste de bits.
Traitement progressif grâce à l’évaluation paresseuse
- La transformation clé de l’encodage est
concatMap codeFor str.- Conceptuellement, on transforme
[Char]en[[Bit]], puis on aplatit le tout en[Bit]. - Grâce à l’évaluation paresseuse de Haskell, cela ne consiste pas à encoder toute l’entrée d’abord, puis à la concaténer ensuite.
- Conceptuellement, on transforme
- Les petites listes sont aplaties de gauche à droite dans une grande liste de sortie.
- La queue de la liste de sortie reste un thunk non encore évalué.
- La partie suivante n’est calculée que lorsqu’elle est demandée.
- Le décodeur génère lui aussi le résultat progressivement.
decode :: FreqMap -> [Bit] -> Stringse déplace à gauche ou à droite dans l’arbre en fonction des bits.- Quand il atteint une feuille, il émet le caractère puis repart de la racine.
- Il répète jusqu’à ce que le nombre total de caractères décodés soit égal au poids de l’arbre de Huffman.
- Le décodeur s’arrête non pas à la fin de la liste de bits en entrée, mais en fonction du nombre de caractères.
- En effet, l’étape de sérialisation ajoute à la fin des bits de remplissage pour aligner sur l’octet.
- Dans la fonction
go, lorsqu’une feuille est atteinte, la tête de la liste est connue et la queue est fournie par un appel récursif, ce qui permet d’évaluer le résultat avant la fin de toute la récursion.
Traitement des fichiers binaires et sérialisation
- Les données binaires peuvent être vues comme la répétition de l’un des 256 octets possibles.
Data.ByteString.Char8permet de manipuler unByteStringavec des opérations surChar, chaqueCharétant tronqué à 8 bits.- Grâce à cette propriété, le codeur initialement pensé pour du texte peut être appliqué à des données binaires sans modification majeure.
- Le fichier compressé commence par le
FreqMapnécessaire au décodage, suivi du flux de bits encodé. serializeFreqMapécrit la table des fréquences au format suivant.- La longueur de la map est stockée en
Word8, en soustrayant 1 pour s’adapter à la plage représentable. - Chaque entrée stocke la clé en
Word8et la fréquence en entier 64 bits big-endian.
- La longueur de la map est stockée en
serializeutilise la monadePutdu packagebinarypour générer unByteString.- Les bits sont lus un à un pour remplir un octet.
- Une fois 8 bits accumulés, ils sont écrits avec
putWord8. - Le dernier octet est complété avec des
Zerosi nécessaire.
Désérialisation et stratégie en mémoire constante
deserializeFreqMaplit la table des fréquences sérialisée avecData.Binary.Get.- Il lit d’abord la longueur, puis ajoute 1 pour retrouver le nombre réel d’entrées.
- Il lit ensuite, pour chaque entrée, la clé
Word8et la fréquence 64 bits afin de reconstruire leFreqMap.
- Le reste de l’entrée n’est pas traité avec
Get: on récupère la partie duByteStringaprès l’offset, puis on la convertit en liste de bits. deserializerenvoie(FreqMap, [Bit]), où[Bit]est une liste paresseuse qui n’est pas entièrement calculée immédiatement.- Il faut éviter de demander la longueur de cette liste, car cela forcerait son évaluation complète.
- Si l’entrée entière n’est pas traitée via
Get, c’est parce que lebindde la monade impose un ordre strict.- On se retrouverait alors avec une structure qui ne peut renvoyer la liste qu’une fois tout le traitement de l’entrée terminé.
- La stratégie en mémoire constante consiste à n’évaluer que la petite portion suivante de l’entrée à chaque écriture de bits de sortie.
- Une portion du
ByteStringest évaluée et le fragment de fichier correspondant est lu. - La sortie traitée est écrite dans le fichier.
- Les fragments d’entrée et les listes de bits qui ne sont plus référencés peuvent être récupérés par le garbage collector.
- Une portion du
- Le
FreqMapcontient au maximum 256 entrées au niveau des octets, ce qui maintient un surcoût de taille constante.
CLI de compression et décompression de fichiers
compresslit le fichier d’entrée deux fois.- Le premier passage construit le
FreqMap. - Le second encode les données à partir de ce
FreqMap.
- Le premier passage construit le
- Si l’on ne lisait le fichier qu’une seule fois pour transmettre la même référence à
encode, il faudrait conserver la référence à l’ensemble du fichier en mémoire même après la création de la table des fréquences. - La lecture en deux passes permet de libérer progressivement la mémoire traitée, aussi bien lors de la construction de la table des fréquences que pendant l’encodage.
decompresslit le fichier compressé, obtientFreqMapet liste de bits viadeserialize, puis écrit dans un fichier le résultat dedecode.- La CLI accepte les arguments suivants.
compress FILE FILEdecompress FILE FILE
- Comme seuls les packages inclus avec GHC sont utilisés, on peut compiler avec
ghc -O2 Main.hs -o mainsanscabal.
Résultats d’exécution et utilisation mémoire
- Lors d’un test sur le fichier texte War and Peace de Tolstoï, le résultat après compression puis décompression est identique selon
diff.- Original
WarAndPeace.txt: 3.2M - Fichier compressé : 1.9M
- Fichier décompressé : 3.2M
- La taille diminue d’environ 40 %.
- Original
- La compression et la décompression fonctionnent aussi sur un fichier binaire plus volumineux,
ghcup.- Original
ghcup: 106M - Fichier compressé : 84M
- Fichier décompressé : 106M
- Le temps de compression est d’environ 15.173 secondes, et celui de décompression d’environ 14.555 secondes.
- Original
- D’après
+RTS -s, la taille maximale de resident set size pendant le traitement deghcupreste inférieure à 300KB. - Les deux processus utilisent moins de 10MB de mémoire à l’exécution.
- La répartition du temps est visible dans un profil séparé.
Pistes d’amélioration
- L’objectif de cette implémentation était de produire un utilitaire de compression aussi simple et clair que possible ; pour améliorer l’efficacité, une implémentation plus complexe serait nécessaire.
- Voici quelques pistes possibles.
- Multithreading : décoder en parallèle des segments de fichier, mais comme on ne peut pas connaître les frontières des mots de code à une position arbitraire, il faudrait ajouter au début du fichier compressé une table des frontières de segments et de la taille décodée attendue.
- Encodage en un seul passage : partir d’une table de fréquences initiale où tous les octets ont la fréquence 1, puis encoder chaque octet avant de mettre à jour la table lorsqu’il apparaît.
- Canonical Huffman codes : au lieu de parcourir l’arbre en
O(log n)au décodage, on pourrait utiliser les codes comme index dans un vecteur pour viser un accès enO(1); voir Canonical Huffman code. - Génération de codes plus rapide : dans un encodage en un seul passage, la construction du
CodeMapdevrait être bien plus rapide, et il existe des méthodes plus efficaces pour générer les mots de code sans construire l’arbre.
- À terme, l’utilisation d’une approche par dictionnaire adaptatif comme LZ77, combinée aux codes de Huffman, permettrait d’implémenter gzip.
1 commentaires
Avis sur Hacker News
Il existe pour cette tâche un algorithme en place basé sur des tableaux qui réduit l’allocation d’arbres et le suivi de pointeurs.
Quand j’ai appris l’approche fondée sur les arbres à l’université, je ne savais pas qu’il existait une autre méthode, et je me demande si c’était aussi le cas d’autres personnes.
La méthode avec arbre est intuitive et aide à comprendre, mais les situations où la compression compte le plus sont généralement celles où il y a beaucoup de données et où l’on veut que ça s’exécute vite ; manipuler le tout en place dans des tableaux peut donc être plus logique.
In-Place Calculation of Minimum-Redundancy Codes, Moffat, Katajainen, 1995
http://hjemmesider.diku.dk/~jyrki/Paper/WADS95.pdf
Charles Bloom l’a fortement recommandé et y a ensuite ajouté des explications.
https://cbloomrants.blogspot.com/2010/08/08-12-10-lost-huffm...
Dire que « pour éviter toute ambiguïté, aucun mot de code ne doit être le préfixe d’un autre mot de code » n’est pas strictement exact.
Les codes dits à décodage unique ne sont pas ambigus, et forment un sur-ensemble des codes préfixes.
Un exemple simple est un code obtenu en inversant un code préfixe. Avec l’exemple de l’article, cela donnerait
a 1,b 00,c 10.Le code de
aest un préfixe du code dec, mais si l’on traite la suite de codes en sens inverse, on peut toujours la décoder sans ambiguïté. Il serait intéressant de voir des codes à décodage unique qui ne soient ni des codes préfixes ni leur version inversée.Si l’on prend
A 0,B 01,C 11, puisa A 0,b BA 010,c BB 0101,d BC 0111,e C 11, on obtient{a=0,b=010,c=0101,d=0111,e=11}.On peut manifestement le décoder de façon unique : d’abord depuis la fin avec des règles comme
0->A, puis de nouveau depuis le début avec des règles commeA->a.En termes de longueurs, c’est identique au code préfixe optimal
{a=0,b=110,c=1110,d=1111,e=10}, donc c’est l’un des codes optimaux pour la même distribution de probabilités.En même temps, à cause de
a=0etb=010, ce n’est ni un code préfixe ni un code suffixe. En fait, en général, le décodage progressif est impossible dans les deux sens : pour distinguercee...ee?debee...ee?, ou?cc...ccade?cc...ccb, il peut falloir une anticipation infinie même pour identifier un symbole.Je ne sais pas si composer un code préfixe optimal indépendamment et un code suffixe optimal indépendamment préserve toujours l’optimalité, mais dans les cas les plus simples auxquels j’ai pensé, hormis les codes dégénérés 1:1, cela fonctionnait bien.
a 101,b 1.Mais c’est un mauvais code, puisqu’il est toujours préférable de prendre
a=1,b=0.L’inégalité de Kraft indique quels ensembles de longueurs de code peuvent être rendus à décodage unique, et le codage de Huffman permet d’atteindre tous ces ensembles. Donc, si l’on fait du codage de symboles, il n’y a aucune raison d’utiliser un code non préfixe, sauf à passer à une autre méthode comme ANS ou le codage arithmétique.
En revanche, je ne sais pas s’il existe un code à décodage unique qui ait le même ensemble de longueurs qu’un code de Huffman optimal, tout en n’étant ni un code préfixe ni son inverse, un code suffixe.
Si j’y consacrais du temps, je regarderais https://en.wikipedia.org/wiki/Sardinas-Patterson_algorithm et j’essaierais soit de trouver un contre-exemple par force brute, soit de dégager une preuve à partir du fonctionnement de l’algorithme.
a 1,b 101?Ce n’est ni préfixe libre ni suffixe libre, mais chaque apparition de
0correspond à l’apparition deb.Bien sûr, c’est manifestement inefficace ; au final, la question est donc de savoir s’il existe un code optimal qui ne soit ni préfixe libre ni suffixe libre.
En cherchant, j’ai vu que https://blog.plover.com/CS/udcodes.html donne l’exemple d’un code à décodage unique
a 0011,b 011,c 11,d 1110.La seule relation de préfixe est que
cest un préfixe ded, donc il est « presque » préfixe libre. Si un message commence par1, il semble qu’il suffise de trouver le premier0et de regarder si le nombre de1qui le précèdent est pair ou impair ; je comprends donc pourquoi il est à décodage unique.En revanche, mes connaissances en théorie du codage sont trop rouillées pour que je me souvienne comment montrer pour quelle distribution de probabilités il est optimal.
Par exemple, avec
100000000000000001, pour savoir si le premier code estaouc, il faut lire tous les zéros jusqu’à l’endroit où ils s’arrêtent.Je me demande s’il existe, parmi les tutoriels similaires qui construisent progressivement un programme Haskell comme cet article, des ressources couvrant des fonctionnalités plus avancées comme les transformateurs de monades ou les lentilles.
Il couvre aussi d’autres fonctionnalités avancées comme Template Haskell et la concurrence, ainsi qu’un chapitre sur la manipulation de bases de données SQL en Haskell.
C’est un tutoriel pour la bibliothèque FRP Rhine, avec de bons commentaires et tests.
Le cours de programmation fonctionnelle basé sur Scala de Coursera propose aussi un exercice de codage de Huffman assez similaire, avec un correcteur automatique, ce qui est pratique pour celles et ceux qui veulent s’y essayer
https://www.coursera.org/learn/scala-functional-programming?...
La dernière fois que j’ai utilisé un code de Huffman, c’était pour le macroprogramme du processeur MICMAC, autrement dit pour exécuter du texte assembleur avec un minimum de microcycles et de micro-instructions
Je suis parti de l’histogramme des macro-instructions exécutées et, si je me souviens bien, j’ai d’abord écrit un interpréteur en C pour compter combien de fois chaque instruction était exécutée
Ensuite, j’ai créé un programme de microcode à décodage progressif implémentant toutes les macro-opérations ISA nécessaires. Il me semble que l’ISA de macro-instructions obtenue était orientée bit plutôt que byte
Dans la réalité, cela aurait probablement été lent et peu pratique, mais l’intérêt du code de Huffman est qu’il permet d’ajuster la profondeur des préfixes en fonction de la distribution des valeurs, sans devoir créer un code biaisé d’un côté à cause d’un préfixe sur 1 bit
De plus, le microprogramme était un modèle de processeur pipeline non superscalaire, il fallait donc aussi gérer la prédiction de branchement. En cas de mauvaise prédiction, des cycles étaient perdus à cause d’un arrêt du pipeline pendant que le bon branchement se propageait vers l’avant
https://rosettacode.org/wiki/Huffman_coding
Comme il va probablement y avoir des programmeurs Haskell ici, j’aimerais poser une question : aujourd’hui, à quel point Haskell est-il rapide quand il est utilisé par un programmeur qui se soucie de l’optimisation ?
Je suis particulièrement curieux des performances sur des tâches qui bénéficient du SIMD et du calcul numérique, comme les opérations matricielles.
L’essentiel, c’est qu’il est facile d’assembler différentes parties en un programme cohérent et bien structuré. C’est important pour l’ensemble du programme, pas seulement pour des boucles très serrées.
Haskell dispose d’un bon FFI, donc les parties qui nécessitent intrinsèquement une optimisation impérative peuvent descendre vers un langage sans garbage collection. Si l’on encapsule ces parties dans une bibliothèque avec de bons types, on peut exploiter ces performances natives depuis n’importe quel code Haskell dont les types correspondent.
C’est ce que nous faisions chez Meta pour créer des applications Haskell hautes performances. Nous écrivions de grands programmes Haskell beaux et rapides, en y intégrant des composants C++ pour certaines parties spécialisées. 99 % du temps était consacré côté Haskell à composer le tout en une application plus utile.
Cela dit, Haskell n’est pas spécialement lent. On peut prendre l’exemple d’un petit programme qui compte le nombre de bits à 1 dans un fichier.
Compilé avec
-msse4.2, il utilise correctement l’instruction matériellepopcountet traite un fichier d’entrée de 1 Go en0m0,090s. En arrondissant au Mo, il utilise 0 de heap.Compilé sans
-msse4.2, il prend0m0,293s.Je n’ai pas testé le calcul matriciel moi-même, mais comme point de départ je regarderais
repa,accelerateetmassiv.https://hackage.haskell.org/package/repa
https://hackage.haskell.org/package/accelerate
https://hackage.haskell.org/package/massiv
https://gitlab.haskell.org/ghc/ghc/-/issues/7741
Cela pourrait arriver dans GHC 9.12. Cela dit, cela ne vise que les vecteurs 128 bits et, sauf contributions d’autres personnes, ce sera probablement surtout axé sur les opérations en virgule flottante.
Le patch est ici :
https://gitlab.haskell.org/ghc/ghc/-/merge_requests/12860
À un certain niveau, le choix du langage hôte n’a pas beaucoup d’importance. Si l’on prend vraiment la performance au sérieux, on finira de toute façon par déléguer les calculs à l’extérieur.
C’est aussi pour cela que le code d’IA, qui représente probablement l’un des plus gros consommateurs de ressources de calcul au monde, peut être écrit en Python en dehors des bibliothèques de calcul bas niveau.
Pour répondre directement : le compilateur GHC est très bon. Le code haut niveau fonctionne plutôt bien, et dans la plupart des applications réalistes, le goulot d’étranglement de performance n’est pas une question d’opérations scalaires contre SIMD, mais une question d’architecture. La « qualité asymptotique architecturale » de Haskell est assez avantageuse.
Je crois que GHC a, ou est en train d’acquérir, une prise en charge de SIMD, mais je ne me concentrerais pas là-dessus pour évaluer les performances.
Je n’écrirais pas moi-même un algorithme de multiplication matricielle en Haskell, mais si je prenais vraiment la vitesse au sérieux, je ne l’écrirais pas non plus moi-même en Rust ou en C.
On considère souvent le calcul numérique comme un indicateur de performance, mais en pratique très peu de gens y ont leur goulot d’étranglement ; et si c’est le cas, le choix du langage haut niveau compte assez peu.
Les performances de ce style sont généralement suffisantes pour des tâches CLI ou des backends web. Il existe aussi des outils pour écrire du code bas niveau assez rapide, mais ils sont un peu rugueux ; si c’est tout ce que l’on veut faire, ce n’est probablement pas le meilleur outil.
En revanche, quand on a quelques hotspots bien localisés à optimiser, c’est plutôt correct.
Les outils de profiling CPU sont bons, donc trouver et optimiser les hotspots CPU est relativement agréable. En revanche, traquer les fuites mémoire étranges, plus susceptibles d’apparaître à cause de l’évaluation paresseuse, peut être très frustrant.
Si l’on regarde les résultats du benchmarks game, les implémentations Haskell les plus rapides sont en général 2 à 5 fois plus lentes que les versions C les plus rapides, et elles sont écrites dans un style très impératif.
https://benchmarksgame-team.pages.debian.net/benchmarksgame/...
Il semble y avoir une coquille dans le tableau de la section « Creating prefix-free codes ». D devrait être
0010, pas0110.0110pouvait ne pas être ambigu, mais maintenant je comprends.Je me demande ce qu’il y a sur le t-shirt de la femme dans l’image.
Lien direct : https://lazamar.github.io/images/data-compressor.svg