Site logo

Triceraprog
La programmation depuis le Crétacé

  • Forth sur 6502, épisode 2 ()

    Le projet minimal

    Avant de passer à la lecture de l'article 2 de la série Moving Forth, je veux mettre en place un projet « minimal ». Plus exactement, c'est initialement ce que je voulais faire, avec un petit code source pour Famicom qui affiche un texte, un Makefile et bien entendu, un framework de tests.

    Mais les notes que j'avais prises sur la Famicom remontaient à un petit moment, et il y a quelques trucs à initialiser avant d'afficher un caractère à l'écran. J'ai donc cherché un squelette de projet déjà fait. Sur une machine populaire, ça doit bien exister.

    Mon choix s'est porté sur Nes Template de Mike Moffitt. Le squelette n'est pas minimaliste, mais a une bonne base, avec un système de configuration flexible pour la ROM destination, et assez de quoi écrire un message à l'écran, ainsi que manipuler des banques de mémoire. C'est bien écrit, bien commenté. Ça me convient bien.

    J'y ai ajouté un script Lua pour automatiser des tests en utilisant l'émulateur Mesen2. Le script place une callback à une adresse mémoire spécifique et s'y arrête lorsque cette adresse est atteinte par le registre PC.

    Et voilà ce que cela donne :

    mesen --testrunner out/nes_template.nes tests/tests.lua
    -- STARTING TESTS --
    Setting callback at address: 0xC0D2
    Memory position reached: 0xC0D2
    

    Le mapping mémoire

    À la fin de l'article précédent, j'avais évoqué le mapping mémoire afin de vérifier les contraintes de la Famicom.

    Voici un mapping rapide (que l'ont peut retrouver plus en détail sur Nesdev) :

    $0000-$00FF : Page zéro     (256 octets)
    $0100-$01FF : Pile          (256 octets)
    $0200-$07FF : RAM interne   (1536 octets)
    
    $2000-$2007 : Registres PPU (le processeur graphique)
    $4000-$4017 : Registres APU (le processeur audio) et E/S
    
    $6000-$7FFF : RAM sur cartouche     (si présente)
    $8000-$FFFF : PRG ROM sur cartouche (si présente)
    

    Les deux dernières zones sont dépendantes de la cartouche et du « mapper » utilisé. Dans la configuration par défaut du projet template que j'utilise, il n'y a pas de RAM configurée sur la cartouche. Il y a par contre 16 banques de 16 Ko de PRG ROM (de la ROM accessible par le CPU), mappée de $8000 à $FFFF. Une banque de 16 Ko est fixe (la dernière, de $C000 à $FFFF) et l'une des autres banques peut être mappée de $8000 à $BFFF. Certaines de ces banques sont utilisées pour stocker les ressources graphiques, qui sont ensuite envoyées à de la mémoire vidéo présente sur la cartouche.

    Il faudra très certainement ajouter de la RAM sur cartouche pour faire quelque chose d'intéressant avec Forth. Je verrai à la fin du développement quel mémoire serait nécessaire pour une production sur cartouche. De toute façon, pendant le développement, j'utiliserai un émulateur la plupart du temps et une cartouche de développement pour vérifier sur vrai matériel.

    Le projet template permet de réserver assez facilement des pointeurs dans la page zéro. Il définit d'ailleurs déjà un certain nombre de variables temporaires. Je vais tout simplement utiliser ce système pour déclarer les registres Forth.

    Pour la pile des paramètres, je vais m'inspirer de ce que le template utilise pour sa queue de commandes graphiques : réservation d'un espace mémoire non initialisé dans la RAM interne et définition d'un pointeur associé dans la page zéro.

    Moving Forth, article 2

    Dans le deuxième article de la série Moving Forth, l'auteur commence par lister les mots essentiels au Forth. Les mots sur lesquels le reste repose, et qui nécessitent d'être efficace.

    Les mots

    En premier, il y a les mots qui forment l'interpréteur lui-même :

    • NEXT
    • DOCOL (aussi appelé ENTER dans l'article)
    • ;S (aussi appelé EXIT dans l'article)

    NEXT est le cœur de l'interpréteur, le mot qui fait avancer toute la machinerie. Il récupère l'adresse du mot à exécuter depuis l'instruction pointer (IP), puis incrémente l'IP pour le mot suivant. Ensuite, il récupère l'adresse de la routine associée au mot (dans le cas de l'Indirect Threaded Code, choisi ici, c'est une adresse mémoire qui pointe vers l'adresse de la routine) et saute à cette adresse pour exécuter le mot. Il vaut mieux lire cette ligne lentement...

    C'est évidemment le premier mot à implémenter. Et il est d'ailleurs possible de faire tourner un petit programme avec seulement ce mot. NEXT ne fait qu'appeler une liste de routines. Et ces routines appellent à leur tour NEXT pour continuer l'exécution.

    DOCOL est le mot qui gère l'appel d'un mot défini par d'autres mots. Il sauvegarde l'IP actuel sur la pile de retour, puis positionne l'IP à l'adresse du code du mot appelé. Ensuite, il saute à NEXT pour continuer l'exécution.

    ;S est le mot qui gère le retour d'un mot défini par d'autres mots. Il récupère l'adresse de retour depuis la pile de retour et la place dans l'IP, puis saute à NEXT pour continuer l'exécution.

    Ensuite, l'article liste les deux mots de constructions que sont DOVAR et DOCON, respectivement pour définir des variables et des constantes. Ainsi que LIT, pour charger une valeur littérale sur la pile de paramètres.

    Il liste aussi DODOES que je laisserai de côté dans un premier temps. C'est un mot un peu plus complexe qui permet l'extension des mots définis par BUILD/DOES>>.

    Et bien entendu, les mots standards :

    • @ (fetch)
    • ! (store)
    • + (addition)
    • SWAP
    • OVER
    • ROT
    • 0=
    • +!

    Les études de cas

    L'article aborde ensuite plusieurs études de cas avec différents processeurs. Il ne cite pas le 6502, je ne m'y attarde donc pas.

    Conclusion

    L'étape suivante sera l'implémentation de au moins NEXT, DOCOL et ;S, afin de pouvoir exécuter un petit programme composé de routines en assembleur. Comme je compte tester le programme, il me faudra aussi un framework de test un peu plus sérieux que le petit script Lua que j'ai écrit pour l'instant.

    En attendant, j'ai placé les registres Forth dans la page zéro et réservé un espace mémoire pour la pile des paramètres. Le test donne à présent :

    mesen --testrunner out/nes_template.nes tests/tests.lua
    -- STARTING TESTS --
    Found symbol REG_W at 0x0010
    Found symbol REG_IP at 0x0012
    Found symbol REG_PSP at 0x0014
    Found symbol REG_TOS at 0x0016
    All required symbols found
    Setting callback at address: 0xC0D2
    Memory position reached: 0xC0D2
    

  • Forth sur 6502 ()

    Family Forth, le projet

    En m'intéressant au langage de programmation Forth, j'ai parfois croisé l'affirmation que pour bien comprendre Forth, il fallait en développer un.

    C'est cohérent avec son histoire, qui débute comme une trousse à outils de son inventeur pour lui faciliter les développements qu'il fait pour ses employeurs.

    J'ai lu un certain nombre de documents sur Forth, parcouru quelques implémentations et même développé un jeu dans ce langage pour Hector HRX.

    Plus récemment, en m'amusant avec le BASIC pour Famicom, je me suis demandé ce qui pourrait être développé sur la console pour tirer parti du clavier. Alors, pourquoi pas un Forth interactif sur Famicom ?

    Une rapide recherche m'a montré que des expériences en Forth pour Famicom avaient déjà été faites, mais pas forcément sous l'angle interactif. Et bien entendu, il existe déjà des Forth pour 6502, le processeur qui équipe cette machine (même si c'est une version custom, cela ne change pas grand chose).

    Mais la prémisse reste : l'intérêt est de développer son propre Forth. Et c'est donc ce que je vais faire dans une série d'articles qui me serviront à poser mes idées, comme pense bête. Et pourquoi pas comme inspiration pour d'autres, si vous voulez bien me suivre, qui sait ?

    Pour initier le développement, j'ai décidé de suivre la série d'articles Moving Forth, qui a été publiée à partir du numéro 59 du « The Computer Journal », en janvier/février 1993.

    Cette série d'articles est intéressante car elle étudie pour différentes étapes les options d'implémentations qui s'offrent, avec des références. Elle donne aussi des implémentations pour divers CPUs, mais pas pour 6502, ce qui me laissera un peu de choix dans mes réflexions, même si je doute que ceux-ci soient très originaux.

    Ces articles étant assez anciens, ils renvoient aussi à une conception pas trop moderne de Forth, qui a évolué depuis, et donc plus proche de ce qu'on pourrait attendre sur Famicom. Enfin peut-être, on verra.

    Le 6502 est aussi un CPU que j'ai très peu pratiqué, c'est donc une occasion de gagner en expérience dessus.

    Moving Forth, article 1

    De chaque article de Moving Forth, je vais retirer les informations qui me semblent importantes dans la démarche de développement. Et je commence donc par... le premier article.

    Quelques choix

    Tout d'abord, la taille des mots. Sur un tel processeur, le choix est assez facile : 16 bits est le minimum pour manipuler des adresses mémoire. Les mots seront donc sur 16 bits. L'article rappelle d'ailleurs qu'il vaut mieux utiliser le mot « cellule » (CELL) car un « mot » est quelque chose d'autre en Forth.

    Vient ensuite le choix du modèle de fonctionnement. Il y a plusieurs moyens de faire fonctionner un Forth, l'article mentionne le « Indirect Threaded Code » (ITC), le « Direct Threaded Code » (DTC), le « Subroutine Threaded Code » (STC) et le « Token Threaded Code » (TTC). Il mentionne aussi le « Segment Threaded Code », mais c'est particulier au 8086, donc pas pertinent ici.

    Le choix classique pour des machines 8 bits est le « Indirect Threaded Code ». Je vais suivre le mouvement et choisir cette méthode. L'article indique que ça n'est ni la méthode la plus rapide ni la plus compacte, qu'elle a un avantage de simplicité, mais que c'est surtout une habitude historique.

    Cependant, c'est aussi la méthode dont j'ai l'habitude lorsque j'ai pratiqué Forth sur d'autres machines 8 bits. Je me note cependant dans un coin de peut-être étudier plus tard les autres méthodes.

    Registres

    Il nous faut aussi des registres. On parle là de registres Forth, et de la manière dont ils seront implémentés sur le CPU, ici le 6502.

    Voici la liste des registres pour lesquels il faudra trouver une place, par ordre d'importance décroissante :

    • W (working register)
    • IP (instruction pointer)
    • PSP (parameter stack pointer)
    • RSP (return stack pointer)
    • TEMP (temporary register)

    Rappel, tous ces registres doivent être sur 16 bits.

    L'article mentionne un registre TOS (top of stack), optionnel mais qui peut optimiser certaines opérations. Pour le moment, je vais le laisser de côté.

    Il est aussi fait mention d'un mapping possible des registres sur 6502, que voici :

    * W, emplacement en page zéro
    * IP, emplacement en page zéro
    * PSP, registre `X` du 6502
    * RSP, registre `SP` du 6502
    * TOS, emplacement mémoire
    

    X et SP étant des registres 8 bits, cela limite la taille de la pile de paramètres et de la pile de retour. À voir si c'est un problème ou pas.

    Ce qui me fait penser qu'il y aura tout intérêt d'écrire un maximum de mots Forth à partir d'autres mots Forth et de limiter les mots en assembleur, au moins dans un premier temps. Ceci afin de limiter la réécriture de routines si je change d'avis sur le mapping des registres.

    Conclusion

    Très bien, après tout cela, il est temps de passer à la définition d'un projet minimal sur la machine cible. Entre autre, il faudra vérifier les emplacements déjà réservés, que ce soit dans la page zéro ou ailleurs en mémoire.


  • Compression de données, Huffman ()

    Voici un nouvel article de la série sur la compression de données, qui fait suite à celui sur la compression RLE, la compression LZ et la compression ZX0.

    À la fin de l'article sur la compression LZ, il avait été question de fréquence d'apparition des nombres pour les offsets de référence et les longueurs de motif. Cette constatation avait amené lors de l'article sur ZX0 à regarder le codage gamma, qui permet de coder des entiers avec un nombre de bits variable afin d'optimiser la taille des petits nombres (qui sont plus fréquents).

    Revenons sur cette histoire de fréquence en l'étendant à tous les symboles présents dans les données à compresser. Il serait intéressant d'avoir un codage dépendant de la fréquence d'apparition des symboles : plus le symbole est fréquent, plus son code est court.

    Ce codage est obtenu avec l'algorithme de Huffman.

    En plus d'avoir des codes de longueurs variables en fonction de la fréquence, Huffman garantit qu'aucun code n'est le préfixe d'un autre code, ce qui rend l'algorithme de décodage très facile. Le codage est aussi optimal : il n'y a pas de codage préfixe plus court pour les fréquences données.

    Algorithme de compression de Huffman

    L'idée générale de l'algorithme est la suivante : après avoir calculé les fréquences d'apparition des symboles, on construit un arbre binaire où chaque symbole est une feuille de l'arbre, avec les symboles les plus fréquents proches de la racine. Le code d'un symbole correspond alors au parcours depuis la racine jusqu'à la feuille, en prenant 0 pour une branche et 1 pour l'autre.

    Pour initier la construction de l'arbre, on crée une liste de nœuds, un par symbole associé à sa fréquence. Ensuite et itérativement jusqu'à avoir l'arbre complet, on prend les deux nœuds de plus petite fréquence et on crée un nouveau nœud parent avec comme fréquence la somme des deux fréquences.

    Reprenons l'exemple qui nous suit depuis le début. Pour rappel, il s'agit de cette image :

    ................
    ....#######.....
    ..##@@@@@@@##...
    .##@OOOOOOO@##..
    .#@OoooooooO@#..
    #@OoooooooooO@#.
    #@OoooooooooO@#.
    #@OoooooooooO@#.
    #@OoooooooooO@#.
    #@OoooooooooO@#.
    #@OoooooooooO@#.
    .#@OoooooooO@#..
    .##@OOOOOOO@##..
    ..##@@@@@@@##...
    ....#######.....
    ................
    

    On commence donc par calculer les fréquences (ou occurrences, cela revient au même) des symboles :

    Fréquences des symboles :
      '.': 78 (28.78%)
      'o': 68 (25.09%)
      '#': 46 (16.97%)
      '@': 34 (12.55%)
      'O': 30 (11.07%)
      '\\n': 15 (5.54%)
    

    Donc, plein de '.' et de "o", et pas beaucoup de retours à la ligne. À noter qu'avec 6 symboles différents, on pourrait utiliser un codage fixe sur 3 bits par symbole (2^3 = 8 possibilités). Voyons ce que donne Huffman en déroulant l'algorithme :

    • On prend les deux nœuds de plus petites occurrences : '\n' (15) et 'O' (30)
    • On crée un nœud parent "\nO" avec occurrences 45 (15 + 30)
    • On prend les deux nœuds de plus petites occurrences : '@' (34) et le "\nO" (45)
    • On crée un nœud parent "@\nO" avec occurrences 79 (34 + 45)
    • On prend les deux nœuds de plus petites occurrences : '#' (46) et 'o' (68)
    • On crée un nœud parent "#o" avec occurrences 114 (46 + 68)
    • On prend les deux nœuds de plus petites occurrences : '.' (78) et le "@\nO" (79)
    • On crée un nœud parent ".@\nO" avec occurrences 157 (78 + 79)
    • On prend les deux nœuds de plus petites occurrences : "#o" (114) et ".@\nO" (157)
    • On crée un nœud racine avec occurrences 271 (114 + 157)

    Ce qui nous amène à l'arbre suivant :

                [271]
               /     \
       <-- 0  /       \  1 -->
             /         \
          [114]        [157]
         /     \      /     \
       [46]   [68]  [78]   [79]
         #      o     .   /    \
                        [34]  [45]
                          @  /    \
                            [15] [30]
                             \n    O
    

    Pour trouver les codes associé à chaque symbole, on parcourt l'arbre depuis la racine jusqu'à chaque feuille, en prenant 0 pour la branche de gauche et 1 pour la branche de droite. Et cela donne :

      '.'  : 10   (longueur=2)
      'o'  : 01   (longueur=2)
      '#'  : 00   (longueur=2)
      '@'  : 110  (longueur=3)
      'O'  : 1111 (longueur=4)
      '\\n': 1110 (longueur=4)
    

    En prenant la moyenne des longueurs pondérées par les occurrences, on obtient une taille moyenne de 2.45 bits environ par symbole. C'est mieux que les 3 bits fixes, mais pas extraordinaire non plus en considérant que 3 bits auraient pu coder 8 symboles.

    Cependant, il n'y a pas besoin de faire l'analyse au préalable et à la main pour savoir combien de bits sont nécessaires. L'algorithme de Huffman le fait tout seul.

    La compression elle-même consiste ensuite à remplacer chaque symbole par son code binaire. Pour notre exemple, cela donne pour la première ligne la suite de bits suivante : 10101010101010101010101010101010, ce qui fait une suite d'octets : 0xAA 0xAA 0xAA 0xAA. On passe de 16 octets à 4. On voit aussi qu'un motif se répète... intéressant non ? J'évoquerai cela à la fin de l'article.

    Algorithme de décompression

    La décompression est très simple : avec le flux compressé et l'arbre, on parcours l'arbre en lisant les bits un par un. À chaque 0, on descend sur la branche de gauche, à chaque 1 sur la branche de droite. Lorsqu'on atteint une feuille, on émet le symbole correspondant et on repart de la racine.

    Je vous laisse faire l'exercice avec 0xAA (c'est-à-dire 10101010) pour retrouver une série de quatre '.'.

    Et l'arbre ?

    C'est bien beau d'avoir codé les données grâce à l'arbre. Mais il nous faut l'arbre pour décoder le flux compressé. Il est donc nécessaire de le transmettre avec les données compressées. Il y a plusieurs manières de faire, mais voici une approche simple :

    • Pour chaque nœud interne, on écrit un 0 (en bit) et on sérialise récursivement les deux nœuds enfants.
    • Pour chaque feuille, on écrit un 1 suivi du code du symbole (sur 8 bits).

    En commençant par la racine, on obtient un parcours en profondeur de l'arbre. Pour notre exemple, cela donne la séquence de bits suivante (les caractères sont ici pour la lisibilité, mais en pratique ce sont leur représentation binaire qui est utilisée) :

    0
      0
        1 `#`
        1 `o`
      0
        1 `.`
        0
          1 `@`
          1 `\n`
        1 `O`
    

    Cela fonctionne puisque, par construction, chaque nœud interne a toujours deux enfants, sauf s'il n'y a qu'un seul symbole dans les données.

    L'exemple ici prend 8 octets (avec quelques bits non utilisés dans le dernier octet). C'est assez petit par rapport aux données, ça va.

    Les chiffres

    Avec les données de l'exemple,. on obtient les résultats suivants :

    • Taille originale : 271 octets (2168 bits)
    • Taille compressée : 666 bits (84 octets, avec 6 bits de padding)
    • Taille de l'arbre : 8 octets
    • Taille totale compressée : 92 octets

    Ce qui donne une taille finale compressée, arbre compris, de 33.95% de la taille originale.

    Combinaisons

    Si on regarde le flux compressé (hors arbre), voici ce que cela donne en hexadécimal :

    0xAA 0xAA 0xAA 0xAA 0xEA 0xA0 0x00 0x2A 0xAE 0xA0 0xDB 0x6D 0xB0 0x55 0xD0 0x6F 0xFF 0xFF 0xFF 0xC1 0x5D 0x1B 0xD5 0x55 0xFC 0x57 0x1B 0xD5 0x55 0x5F 0xC5 0xC6 0xF5 0x55 0x57 0xF1 0x71 0xBD 0x55 0x55 0xFC 0x5C 0x6F 0x55 0x55 0x7F 0x17 0x1B 0xD5 0x55 0x5F 0xC5 0xC6 0xF5 0x55 0x57 0xF1 0x74 0x6F 0x55 0x57 0xF1 0x5D 0x06 0xFF 0xFF 0xFF 0xFC 0x15 0xD4 0x1B 0x6D 0xB6 0x0A 0xBA 0xA8 0x00 0x0A 0xAB 0xAA 0xAA 0xAA 0xAA 0x80 
    

    Comme indiqué plus haut, on remarque des motifs qui se répètent. Ce flux est donc probablement un bon candidat pour une compression supplémentaire avec LZ par exemple. Pourquoi pas ?

    L'algorithme DEFLATE (utilisé dans le format .zip par exemple) fait le contraire : il applique d'abord LZ77 pour compresser les motifs répétitifs, puis applique Huffman pour compresser les symboles résultants. C'est un peu plus compliqué que cela, mais c'est l'idée générale, et c'est l'idée générale derrière plusieurs formats de compressions : des mélanges d'algorithmes.

    Conclusion

    Huffman est un algorithme de compression efficace sur des données avec des fréquences d'apparition de symboles non uniformes. Si tous les symboles sont également fréquents, il n'y aura pas d'avantage à utiliser Huffman par rapport à un codage fixe. De plus, il faut stocker l'arbre, il est donc important de vérifier que la taille gagnée par la compression compense la taille de l'arbre (il est aussi possible d'utiliser des arbres statiques connus à l'avance pour certains types de données, mais cela sera potentiellement moins efficace).

    On retrouve cet algorithme dans de nombreux formats de compression, souvent combiné avec d'autres algorithmes. C'est une des bases de la compression de données, il est donc intéressant de connaître son principe de fonctionnement.

    Cet article termine la petite série sur la compression de données sans perte.

    Supplément

    Le code que j'ai utilisé lors de l'écriture pour vérifier les exemples est disponible ici.


  • Compression de données, le format ZX0 ()

    Après avoir présenté dans l'article précédent sur la compression LZ l'idée générale de référencement arrière de données déjà présentes en mémoire, cet article va se concentrer sur une implémentation spécifique de la famille LZ : le ZX0, un algorithme développé par Einar Saukas avec pour double objectif une taille très compacte pour le code de décompression et une recherche des choix optimaux lors de la compression.

    Au niveau du code de décompression, sur Z80 et pour reprendre les exemples fournis dans le README officiel, on a les chiffres suivants :

    • Routine "Standard" : 68 octets
    • Routine "Turbo" : 126 octets, environ 21% plus rapide
    • Routine "Fast" : 187 octets, environ 25% plus rapide
    • Routine "Mega" : 673 octets, environ 28% plus rapide

    Pour obtenir un code de décompression aussi compact, les choix faits dans le format ZX0 sont adaptés à un décodage avec peu de calculs et des calculs simples.

    Rentrons dans les détails.

    Le double flux

    ZX0 utilise deux flux de données distincts. Le premier flux contient les informations sur les blocs : quel type de bloc (littéral ou référence arrière), les offsets et les longueurs. Ce flux est lu bit par bit. Le second flux contient les données littérales elles-mêmes, lues octet par octet.

    Il suffit donc d'avoir une routine qui lit le bit suivant du premier flux et une routine qui lit l'octet suivant du second flux. Un pointeur suffit pour le second flux. Le premier flux est un peu plus complexe : il nécessite un pointeur, un octet courant et un compteur de bits restants dans l'octet courant. Cela reste très simple.

    Ces deux flux sont entrelacés. Lorsque l'une des deux routines a besoin d'un nouvel octet, elle le lit depuis les données compressées. Cela signifie qu'en fait, un seul pointeur est nécessaire pour parcourir les deux flux. De manière symétrique, cela signifie que lors de la compression, les données doivent être écrites en un seul flux en entrelaçant les deux types de données au fur et à mesure qu'elles forment un octet complet.

    Encodage des nombres

    À la fin de l'article précédent, la constatation était que les références avaient souvent des petites longueurs et qu'il pouvait être intéressant de trouver un moyen d'encoder ces petites longueurs de manière efficace.

    ZX0 prend acte et utilise un codage nommé « codage gamma » ou encore « codage gamma d'Elias » ("Elias Gamma coding").

    Principe du codage gamma

    Le codage gamma propose d’écrire un nombre en deux parties : la longueur binaire nécessaire et la valeur restante depuis la puissance de 2 inférieure.

    La longueur du nombre en binaire est encodée de manière unaire : on regarde combien de bits sont nécessaires pour écrire le nombre en binaire, on écrit cette valeur moins un en unaire (avec autant de 0 que le nombre voulu), puis on termine avec un 1. Incidemment, cela représente la puissance de deux inférieure nécessaire pour coder le nombre.

    Par exemple, pour le nombre 3, qui s'écrit 11 en binaire (2 bits), on écrit 01 (un 0 suivi d'un 1). Pour le nombre 10, qui s'écrit 1010 en binaire (4 bits), on écrit 0001 (trois 0 suivis d'un 1).

    Pour la valeur restante, on prend le nombre, on enlève la plus grande puissance de 2 qui ne dépasse pas ce nombre, puis on écrit le reste en binaire, sur le nombre de bits calculé précédemment (le nombre de fois où on a trouvé 0 avant le 1).

    Par exemple, pour le nombre 3, la plus grande puissance de 2 inférieure est 2 (\(2^1\)), donc le reste est \(3 - 2 = 1\), qui s'écrit 1 en binaire sur 1 bit. Pour le nombre 10, la plus grande puissance de 2 inférieure est 8 (\(2^3\)), donc le reste est \(10 - 8 = 2\), qui s'écrit 010 en binaire sur 3 bits.

    Ainsi on a :

    3  ->   01 1    (pour un total de 3 bits)
    10 -> 0001 010  (pour un total de 7 bits)
    

    Ce codage est efficace pour les petits nombres (jusqu'à 15), car il peut les écrire avec moins de 8 bits. En revanche, il devient moins efficace pour les nombres plus grands (par exemple 16 occupe 9 bits en codage gamma).

    Ce codage offre aussi l'avantage d'être sur un nombre de bits variable et de permettre la « découverte » de cette taille lors de la lecture.

    Mais pour rendre le décodage encore plus simple, ZX0 utilise une variante appelée « codage gamma entrelacé » qui permet de lire tous les bits dans l'ordre en une seule passe. L'entrelacement se fait entre la partie unaire et la partie binaire (dans le sens du plus petit bit vers le plus grand). Les bits de la première partie (unaire) sont les bits impaires et les bits de la seconde partie (binaire) sont les bits paires.

    Exemples :

    3  codé   01 1   devient 011
    10 codé 0001 010 devient 0001001
    

    Avec ce système, il suffit de lire un bit et s'arrêter s'il est à 1, sinon lire un autre bit pour la partie binaire, l'ajouter à l'accumulateur préalablement décalé vers la gauche. Puis recommencer jusqu'à trouver un 1. Pour que l'algorithme fonctionne, l'accumulateur doit être initialisé à 1.

    Par exemple, pour décoder 011 :

    • Lire 0 (unaire), on continue
    • Lire 1 (binaire), accumulateur = 1 << 1 | 1 = 3
    • Lire 1 (unaire), on s'arrête, valeur finale = 3

    Et pour décoder 0001001 :

    • Lire 0 (unaire), on continue
    • Lire 0 (binaire), accumulateur = 1 << 1 | 0 = 2
    • Lire 0 (unaire), on continue
    • Lire 1 (binaire), accumulateur = 2 << 1 | 1 = 5
    • Lire 0 (unaire), on continue
    • Lire 0 (binaire), accumulateur = 5 << 1 | 0 = 10
    • Lire 1 (unaire), on s'arrête, valeur finale = 10

    Les types de blocs

    Maintenant que l'on sait qu'il y a deux flux et comment encoder les nombres, voyons comment ZX0 organise les blocs de données. Il existe trois types de blocs :

    • Bloc littéral : contient les données littérales. Est associé à une longueur.
    • Bloc de référence : référence une séquence de données précédemment rencontrée. Est associé à un offset et une longueur.
    • Bloc de référence de même offset : tout comme un bloc de référence, mais réutilise le dernier offset utilisé et ne spécifie donc que la longueur.

    Pour différencier ces trois types de blocs, ZX0 utilise un seul bit. Un seul bit pour désigner trois types de blocs ? Cela est possible car certaines contraintes logiques existent entre les blocs :

    • Deux blocs littéraux ne peuvent pas se suivre (sinon ils seraient fusionnés en un seul bloc littéral).
    • Un bloc de référence de même offset ne peut apparaître qu'après un bloc littéral.
    • Le premier bloc est toujours un bloc littéral (et d'ailleurs le tout premier bit qui devrait indiquer le type de bloc est omis).

    Ainsi, le bloc littéral et le bloc de référence de même offset sont tous deux indiqués par un 0 et le bloc de référence avec nouvel offset est indiqué par un 1. Le contexte permet de savoir quel type de bloc est concerné entre les deux premiers.

    Les longueurs sont encodées en utilisant le codage gamma entrelacé décrit précédemment. Pour les offsets, c'est un peu plus compliqué : l'offset est divisé en deux parties, le MSB (Most Significant Byte) utilise le codage gamma entrelacé, tandis que le LSB (Least Significant Byte) est stocké tel quel sur 7 bits (au lieu de 8). Comme on ne peut pas encoder 0 en gamma, le MSB de l'offset est stocké avec une valeur augmentée de 1.

    Autre petite optimisation : puisqu'une référence avec nouvel offset a toujours une longueur minimale de 2, la longueur stockée pour une référence est en fait la longueur moins 1.

    Exemples :

    0 011               -> bloc littéral de longueur 3
                           (ou bien référence de longueur 3 selon le contexte)
    1 1 0000011 0001001 -> bloc de référence avec nouvel offset de longueur 3
                           (1 en codage gamma pour MSB = 0)
                           (0000011 pour LSB = 3)
                           et longueur 11 (10 + 1)
    

    Recherche optimale

    Lors de l'article précédent qui présentait l'idée derrière la compression LZ, il avait été question de prendre en référence arrière la plus grande séquence identique possible. Cela fonctionne : c'est un type d'algorithme glouton (greedy). Cependant, cette méthode n'est pas forcément (pas souvent ?) optimale. Peut-être que prendre à un endroit le motif le plus long empêche de faire un meilleur choix de référence un peu plus loin. Ou bien peut-être que cela cause des codages gamma sur trop de bits. L'algorithme ZX0 fait le choix de trouver la solution optimale.

    Pour cela, à chaque position lors de la compression, l'algorithme va examiner tous les offsets possible et regarder s'il est possible de faire une référence arrière et maintenir une chaîne optimale en calculant le coût total si une référence devait être faite à cet endroit.

    L'algorithme fait appel à de la programmation dynamique pour obtenir un code compact et efficace. Efficace, mais pas rapide : la compression avec ZX0 est lente avec des fichiers un peu conséquents, c'est son principal inconvénient. Mais en échange, on est assuré que, avec les choix de codage de ce format, le résultat est le plus compact possible.

    Test sur l'exemple

    Reprenons à nouveau l'image en niveaux de gris des deux articles précédents et voyons ce que cela donne avec le format ZX0.

    L'exemple, pour rappel :

    ................
    ....#######.....
    ..##@@@@@@@##...
    .##@OOOOOOO@##..
    .#@OoooooooO@#..
    #@OoooooooooO@#.
    #@OoooooooooO@#.
    #@OoooooooooO@#.
    #@OoooooooooO@#.
    #@OoooooooooO@#.
    #@OoooooooooO@#.
    .#@OoooooooO@#..
    .##@OOOOOOO@##..
    ..##@@@@@@@##...
    ....#######.....
    ................
    

    Le résultat de la compression avec ZX0 est le suivant :

    File compressed from 272 to 51 bytes! (delta 3)
    

    Soit un fichier final ayant un peu moins de 19% de la taille originale. Pour rappel, la compression RLE et LZ « naïve » avaient donné un fichier de 57% la taille originale. C'est nettement mieux.

    Voici le contenu de fichier final :

    0000000  95  2e  89  0a  2e  a4  23  f7  de  b9  ed  40  fe  ff  ef  de
    0000010  ee  e0  4f  fe  7f  dc  fb  de  e1  6f  9f  fe  dd  fc  de  e0
    0000020  f7  dc  91  de  1f  12  5d  d7  ce  75  8a  d9  46  1d  d7  02
    0000030  55  55  80  
    

    On peut s'amuser à décoder le début à la main.

    On garde le premier octet $95 (10010101 en binaire) en mémoire, il s'agit des informations de blocs. Le premier bloc est toujours un bloc littéral, on commence donc par une longueur en codage gamma entrelacé. 1 signifie une longueur de 1. On lit donc le premier octet du flux littéral : $2e (le caractère .). Nous voici avec notre premier caractère décodé.

    On continue à lire ce qu'il reste de notre octet $95 : le bit suivant est un 0. Puisque le bloc précédent était un bloc littéral, ce 0 indique un bloc de référence de même offset. Le offset précédent est initialisé à 1 en début d'algorithme. On a donc une référence de 1 en arrière (le caractère . déjà décodé). Reste à lire la longueur en codage gamma entrelacé : 010101... le nombre n'est pas terminé, il nous faut un nouvel octet depuis le flux. C'est $89 (10001001 en binaire).

    On continue la lecture : 1. Le bit de fin du codage gamma. Ce qui donne au final 0101011, soit 0001 111 en désentrelacé, soit une longueur de 15. On copie donc 15 fois le caractère trouvé une position avant. Nous voici avec 16 caractères . décodés. À noter qu'il n'y avait qu'un seul octet dans le flux littéral, mais la copie avance en recopiant le caractère déjà décodé. Avec une référence arrière de 1, il y a autant de copie de . que nécessaire.

    Continuons. Le prochain bloc est indiqué par le bit suivant : 0. Puisque le bloc précédent était une référence, ce 0 indique un bloc littéral. On lit la longueur en codage gamma entrelacé : 001, soit 01 0 en non entrelacé, ce qui signifie 2. On lit donc les deux octets suivants dans le flux littéral : $0a (le caractère de retour chariot) et $2e (le caractère .). Nous venons de compléter la première ligne de l'image et de débuter la seconde.

    Vous pouvez continuer si vous le souhaitez, mais je m'arrête ici pour cet exemple.

    Fonctionnalités avancées

    Avant de refermer cet article, il est intéressant de noter que ZX0 propose quelques fonctionnalités supplémentaires.

    La compression à rebours

    Plutôt que de compresser les données du début à la fin du fichier d'entrée, ZX0 permet de compresser les données en commençant par la fin du fichier d'entrée. L'algorithme est le même, sauf que le flux commence par la fin et que les références « arrières » sont « plus loin » en mémoire (puisque le début du flux est à la fin du fichier).

    Cela est intéressant dans des scénarios où les données compressées et décompressées se recouvrent (ce qui est tout à fait possible aussi avec la compression standard) et que l'on veut « viser » l'adresse mémoire la plus haute disponible.

    En effet, lors d'un recouvrement, l'algorithme à besoin d'un « décalage minimum » entre les deux flux de données (indiqué par le « delta » affiché lors de la compression). Pour bien comprendre ce que signifie ce delta et l'intérêt de la compression à rebours, le mieux est de consulter les diagrammes fournis dans le README.

    Compression avec préfixe

    Très pratique pour des décompressions « par morceaux », il est possible de fournir au compresseur un préfixe de données que l'on sait seront déjà présentes en mémoire lors de la décompression juste avant les nouvelles données à décompresser.

    Le compresseur pourra donc piocher les références dans ce préfixe, sans pour autant inclure ces données dans le flux compressé. Bien entendu, il faut garantir que ces données seront bien présentes en mémoire lors de la décompression, puisque le décompresseur va les référencer.

    Mode rapide

    Enfin, du fait de la recherche optimale, la compression avec ZX0 peut être assez lente. Le compresseur propose donc une option « rapide » pour diminuer le temps de compression. L'option -q a pour effet de réduire fortement la fenêtre de recherche des références arrière qui est normalement de 32640 octets et passe à 2176 octets.

    La recherche optimale est toujours effectuée, mais en allant chercher beaucoup moins loin les blocs pouvant être référencés. Cela diminue fortement le temps de compression, mais potentiellement aussi le taux de compression.

    Pour l'exemple ci-dessus, dont la taille totale est bien inférieure à 2176 octets, cela ne change rien du tout. Ni en temps, ni en taille finale.

    Conclusion

    ZX0 est un algorithme de compression assez populaire sur les machines 8 bits. Le code source est disponible, il est simple à mettre en oeuvre, le code de décompression est très compact. Il a même été porté sur d'autres processeurs que le Z80.

    Bien qu'un peu lent à compresser, le résultat de compression est bon et la décompression rapide. Ça en fait un très bon choix.

    J'espère avoir pu vous donner une idée des choix faits par cet algorithme de compression/décompression et de son fonctionnement. Pas de code source dans cet article, j'ai directement utilisé l'implémentation officielle.


  • Compression de données, référencer les répétitions ()

    Dans l'article précédent sur la compression, l'idée était de remplacer les successions d'éléments identiques par un couple (nombre de répétitions, valeur). Cela fonctionne bien avec des plages de données contenant un même élément, mais beaucoup moins bien lorsque les données changent fréquemment.

    La séquence suivante, par exemple, sera très mal compressée par cette méthode RLE :

    .#.#.#.#.#.#.#.#.#.#.#.#.#.#.#.#
    

    En effet, cette séquence ne contient aucune répétition d'un élément unique. Chaque élément est différent du précédent. Avec la méthode RLE, on devrait écrire :

    (1, .), (1, #), (1, .), (1, #), ... 
    

    On peut avoir envie de se dire, avec cette séquence, que le motif à répéter est .# et qu'on le répète 16 fois. Mais on pourrait aussi se dire que c'est 4 fois le motif .#.#.#.#. Ou encore 2 fois le motif .#.#.#.#.#.#.#.#. Dans tous les cas, il nous faudrait une sorte de dictionnaire pour référencer les motifs que l'on voudrait répéter.

    Et c'est une manière assez courante de compresser des données : référencer un dictionnaire.

    Cependant, un dictionnaire, ça prend de la place. Et dans la compression que nous allons voir aujourd'hui, la méthode est d'utiliser les données elles-mêmes comme dictionnaire pour éviter de construire (et stocker) un dictionnaire à part. C'est la méthode LZ (Lempel-Ziv), du nom de ses inventeurs, ou en tout cas une manière dérivée de LZ.

    Le dictionnaire

    L'idée est la suivante : lorsque l'on en est à un point du flux de décompression, toutes les données précédemment décompressées sont disponibles en mémoire.

    Imaginons par exemple, avec la séquence précédente, que l'on a déjà décompressé les deux premiers caractères : .#. On peut tout à fait commencer à référencer ce motif dans le flux à decompresser qui arrive. La séquence à décompresser suivante pourrait être « répète le motif de longueur 2 qui commence 2 positions avant ». Ce qui donnerait, une fois ajouté aux données déjà décompressées : .#.#.

    À l'étape suivante, on pourrait avoir : « répète le motif de longueur 4 qui commence 4 positions avant ». On aurait à présent : .#.#.#.#

    Et ainsi de suite.

    Codage général

    Ainsi, il nous faut construire un flux compressé qui est une suite d'éléments unitaires (des séquences qui n'ont pas encore été vues) et de références à des motifs déjà vus (avec une paire (position en amont, longueur)).

    Sans entrer dans une implémentation précise, pour le motif précédent, on pourrait avoir ceci :

    .# (suite d'éléments unitaires)
    (-2,2) (référence au motif de longueur 2, 2 positions avant)
    (-4,4) (référence au motif de longueur 4, 4 positions avant)
    (-8,8) (référence au motif de longueur 8, 8 positions avant)
    (-16,16) (référence au motif de longueur 16, 16 positions avant)
    

    Si on utilise un élément marqueur pour indiquer si ce qui suit est une suite d'éléments unitaires ou une référence, cela donnerait globalement 15 éléments pour représenter les 32 éléments de l'original. Soit une taille finale d'un peu plus de 40% de l'original.

    Pas mal.

    Note : en vrai, on ne référencera pas des motifs aussi petits que deux éléments, car le gain en taille ne serait pas suffisant. Avec ce codage simpliste, il faut au moins 4 éléments pour référencer un motif de manière efficace.

    Méthode de compression

    Afin de compresser un flux de données avec cette méthode, à chaque étape, il faut chercher dans le flux en entrée si le motif qui suit est présent dans les données précédentes. Et ce pour la longueur la plus grande possible.

    Pour l'exemple, lorsque l'on commence le flux, le premier caractère est un .. Ce caractère n'est pas encore dans les données présentes (puisque c'est le premier). On l'écrit donc comme élément unitaire dans le flux compressé. De même pour le caractère suivant, un #, que l'on écrit aussi en tant qu'élément unitaires à la suite du premier.

    Pour la suite, on a dans le buffer à traiter une séquence .#.#.#.#.... On cherche le plus long motif qui est déjà dans les données. Ici, c'est .#, que l'on trouve à la position -2 (2 positions avant). On écrit donc la référence (-2,2) dans le flux compressé.

    Et ainsi de suite, jusqu'à la fin du flux d'entrée.

    Essai sur la séquence de l'article précédent

    Je reprends l'image en niveaux de gris de l'article précédent sur la compression RLE :

    ................
    ....#######.....
    ..##@@@@@@@##...
    .##@OOOOOOO@##..
    .#@OoooooooO@#..
    #@OoooooooooO@#.
    #@OoooooooooO@#.
    #@OoooooooooO@#.
    #@OoooooooooO@#.
    #@OoooooooooO@#.
    #@OoooooooooO@#.
    .#@OoooooooO@#..
    .##@OOOOOOO@##..
    ..##@@@@@@@##...
    ....#######.....
    ................
    

    Dans le cas RLE, j'avais supprimé les sauts de ligne pour ne garder qu'une suite d'éléments et profiter des plages formées par la fin d'une ligne et le début de la ligne suivante. Ici, je vais les garder, c'est beaucoup moins génant puisque la méthode compresse les séquences répétées.

    Passé par l'algorithme décrit précédemment, voici ce que l'on obtient :

      Littéral:  (0, '....')
      Référence: (1, (-4, 4))
      Référence: (1, (-8, 8))
      Littéral:  (0, '\n')
      Référence: (1, (-5, 4))
      Littéral:  (0, '#######')
      Référence: (1, (-17, 8))
      Littéral:  (0, '##@@@@@@@')
      Référence: (1, (-19, 5))
      Littéral:  (0, '\n')
      Référence: (1, (-16, 4))
      Littéral:  (0, 'OOOOOOO')
      Référence: (1, (-18, 5))
      Littéral:  (0, '\n.#@OoooooooO@')
      Référence: (1, (-17, 4))
      Référence: (1, (-16, 10))
      Référence: (1, (-18, 6))
      Référence: (1, (-17, 17))
      Référence: (1, (-34, 34))
      Référence: (1, (-34, 34))
      Littéral:  (0, '\n.')
      Référence: (1, (-18, 10))
      Référence: (1, (-16, 4))
      Référence: (1, (-17, 4))
      Littéral:  (0, '#@OOOOOOO@#')
      Référence: (1, (-17, 5))
      Référence: (1, (-18, 4))
      Littéral:  (0, '@@@@@')
      Référence: (1, (-16, 5))
      Référence: (1, (-17, 4))
      Référence: (1, (-19, 4))
      Littéral:  (0, '###')
      Référence: (1, (-15, 5))
      Référence: (1, (-17, 7))
      Référence: (1, (-10, 5))
      Référence: (1, (-7, 7))
    

    Ce qui donne une entrée de 272 éléments (256 éléments plus les retours chariots) compressée en 156 éléments, soit environ 57% de la taille initiale.

    C'est légèrement moins bien que la méthode RLE pour ce cas qui avait été construit spécialement pour que ça fonctionne bien en RLE. Donc... ce n'est pas mal du tout (et on a les retours chariots en bonus).

    Peut-on faire mieux ?

    Comme la dernière fois, pour le moment, on a une implémentation assez générique que l'on compte en éléments. Si on considère que ces éléments sont en fait des octets, il y a beaucoup de place perdue, ne serait-ce que par l'utilisation d'un octet complet pour différencier les littéraux des références.

    De même, on peut se poser la question de l'utilisation d'un octet complet pour la position et la longueur dans les références, alors que les plus grandes valeurs absolues utilisées dans cet exemple sont 34. On pourrait limiter ces valeurs à 4 bits chacun pour coder les deux valeurs sur un seul octet.

    J'ai fait l'essai... et ce n'est pas brillant. La compression est alors assez mauvaise, passant alors à un résultat compressé de 225 octets.

    Par contre, en utilisant une valeur maximale de 32, alors on retrouve le taux de compression précédente.

    Mais plus que la valeur maximale, il est intéressant de s'intéresser à la répartition des valeurs utilisées.

    Grâce à un petit programme d'analyse, voici ce que j'obtiens pour cet exemple :

    Distribution des longueurs de référence:
      Valeur | Occurrences | Histogramme
      -------|-------------|------------
           4 |           9 | #########
           5 |           6 | ######
           6 |           1 | #
           7 |           2 | ##
           8 |           2 | ##
          10 |           2 | ##
          17 |           1 | #
          34 |           2 | ##
      Statistiques: Min=4, Max=34, Moyenne=8.28, Médiane=5.0
    
    Distribution des positions de référence (valeurs absolues):
      Valeur | Occurrences | Histogramme
      -------|-------------|------------
           4 |           1 | #
           5 |           1 | #
           7 |           1 | #
           8 |           1 | #
          10 |           1 | #
          15 |           1 | #
          16 |           4 | ####
          17 |           7 | #######
          18 |           4 | ####
          19 |           2 | ##
          34 |           2 | ##
      Statistiques: Min=4, Max=34, Moyenne=16.40, Médiane=17.0
    
    Distribution combinée (longueurs + positions):
      Valeur | Occurrences | Histogramme
      -------|-------------|------------
           4 |          10 | ##########
           5 |           7 | #######
           6 |           1 | #
           7 |           3 | ###
           8 |           3 | ###
          10 |           3 | ###
          15 |           1 | #
          16 |           4 | ####
          17 |           8 | ########
          18 |           4 | ####
          19 |           2 | ##
          34 |           4 | ####
      Statistiques: Min=4, Max=34, Moyenne=12.34, Médiane=10.0
    

    On peut remarquer que même si l'amplitude des valeurs est grande (de 4 à 34), la moitié des valeurs utilisées sont plutôt petites (entre 4 et 10), et la moyenne n'est pas très élevée non plus. Est-ce que ça ne vaudrait pas le coup d'utiliser un codage variable pour ces valeurs, afin de privilégier les petites valeurs ?

    C'est sur cette piste que part l'algorithme ZX0, que nous verrons dans un prochain article.

    À noter que une piste est possible en calculant une représentation des valeurs adaptée à leurs distributions. Pour cela, on peut utiliser un codage de Huffman, et c'est la piste utilisée par l'algorithme DEFLATE (utilisé dans les fichiers ZIP et PNG entre autres).

    Conclusion

    Avec cette méthode de compression par références dans un dictionnaire construit, il est possible de compresser des flux de données avec beaucoup plus de variations qu'avec la méthode RLE. LZ est en fait la base de beaucoup d'algorithmes de compression sans pertes. Le temps de compression est « élevé » car il est nécessaire de chercher des motifs dans des plages de données (la recherche peut d'ailleurs être paramétrée selon ce que l'on veut). La décompression est en revanche assez rapide, il n'y a pas de calculs ou d'opérations complexes à faire.

    Supplément

    Le code que j'ai utilisé lors de l'écriture pour vérifier les exemples est disponible ici.


Page 1 / 24 (suivant) »

Tous les tags

3d (15), 6502 (2), 6809 (1), 8bits (1), Affichage (24), AgonLight (2), Altaïr (1), Amstrad CPC (1), Apple (1), Aquarius (2), ASM (30), Atari (1), Atari 800 (1), Atari ST (2), Automatisation (4), BASIC (31), BASIC-80 (4), C (3), Calculs (1), CDC (1), Clion (1), cmake (1), Commodore (1), Commodore PET (1), Compression (4), CPU (1), Debug (5), Dithering (2), Divers (1), EF9345 (1), Émulation (7), Famicom (2), Forth (5), Game Jam (1), Hector (3), Histoire (1), Hooks (4), Huffman (1), i8008 (1), Image (17), Jeu (15), Jeu Vidéo (4), Livre (1), Logo (2), LZ (1), Machine virtuelle (2), Magazine (1), MAME (1), Matra Alice (3), MDLC (7), Micral (2), Motorola (1), MSX (1), Musée (2), Nintendo Switch (1), Nombres (3), Optimisation (1), Outils (3), Pascaline (1), Peertube (1), PHC-25 (2), Photo (2), Programmation (7), Python (1), RLE (1), ROM (15), RPUfOS (6), Salon (1), SC-3000 (1), Schéma (5), Synthèse (15), Tortue (1), Triceraprog (1), VG5000 (62), VIC-20 (1), Vidéo (1), Z80 (21), z88dk (1), ZX0 (1)

Les derniers articles

Forth sur 6502, épisode 2
Forth sur 6502
Compression de données, Huffman
Compression de données, le format ZX0
Compression de données, référencer les répétitions
Briques Stellaires, jeu pour PHC-25
Compression de données, compter les répétitions
PHC-25, et Z80 en IM 2
Récréation Famicom
Family BASIC, le BASIC sur Famicom

Atom Feed

Réseaux