Temps d'accès à la VRAM

Problématique



Le processeur graphique du MSX (appelé VDP) fonctionne de façon autonome du processeur central (CPU), le Z80.

Le CPU communique avec le VDP à travers des ports d'entrée/sortie (ports I/O).
Ces ports I/O permettent de lire et/ou d'écrire dans les registres qui contrôlent le fonctionnement du VDP (choix du mode graphique, taille des sprites, etc.), mais aussi de lire ou écrire dans la mémoire vidéo (VRAM) du VDP.

La lecture/écriture en VRAM se fait de façon indirect : on configure les registres du VDP (pour indiquer notamment l'adresse à accéder en VRAM), puis on lit/écrit la VRAM à travers les ports I/O.
Le VDP doit traiter ces demandes d'accès de son coté.
Hors, quand il est occupé à afficher une image à l'écran, le VDP peut prendre plus de temps à répondre à ces demandes que de temps qu'il faut au CPU pour les faire.
Il peut donc arriver que le CPU envois des données trop rapidement au VDP et que certaines soient perdues, faute de temps pour les traiter.
Il en résulte un affichage incorrect des données que le programme souhaitait envoyer.
Idem si le CPU essaye de lire trop vite des données en VRAM : Les valeurs récupérées peuvent être incorrectes.

Le temps de traitements des demandes d'accès à la VRAM dépend de certains paramètres :
- La génération du VDP,
- Le mode écran (screen mode) actuel,
- L'activité du VDP au moment de la demande.

Génération et mode écran



Durant la période commerciale du MSX, 3 générations de VDP ont été utilisés :
- Texas Instrument TMS9918 (MSX1),
- Yamaha V9938 (MSX2),
- Yamaha V9958 (MSX2+ et turbo R).

Voici l'intervalle de temps minimale à respecter entre des accès au VDP en fonction des modes écrans (exprimé en nombre de cycles du CPU).
Screen mode VDP mode MSX1 cycles MSX2/2+ cycles
0,W40 T1 12 20
0,W80 T2 -- 20
1 G1 29 15
2 G2 29 15
3 MC 13 15
4 G3 -- 15
5 G4 -- 15
6 G5 -- 15
7 G3 -- 15
8 G7 -- 15

Bien que plus moderne, le V9938 est moins réactifs que le TMS9918 dans le modes textuel 1 (Screen 0) et multi-couleur (Screen 3).

Activité du VDP



L'activité du VDP au moment de la demande d'accès à un gros impact sur son temps de réaction.
Par ex., lorsque le VDP est en train de "dessiner" les bordures, son temps de réaction est plus rapide que le temps minimum nécessaire pour le CPU pour faire une demande d'accès à la VRAM.

Ainsi, dans certains cas, il n'y a aucun contrainte de temps d'accès à la VRAM :
- Quand le VDP est dans le H-Blank (bordure droite/gauche), ou le V-Blank (bordure bas/haut). Note : Le V-Blank étant relativement long, il est possible d'utiliser ce temps pour lire/écrire des données en VRAM a une vitesse maximale.
- Quand l'affichage est désactivé. Note : Par ex., on peut utiliser un écran noir (affichage désactivé) pour charger plus rapidement un niveau de jeu.

L'activation ou non des sprites a un impact sur la réactivité du VDP, mais l'impact chiffré exacte sur les temps d'accès n'est pas connu.

Accès au VDP en assembleur



Le BIOS permet d'accéder au VDP de façon sécurisé et standard. Cependant, le surcout en performant de passer par le BIOS est loin d'être négligeable et dès l'époque commercial du MSX, les développeurs ont utilisé les accès direct en assembleur pour pouvoir tirer pleinement parti de la puissance du VDP.

Le CPU communiquant avec le VDP via les ports I/O, ce sont les instructions in et out (et leurs dérivés) qui sont utilisés.

Dans la suite de ce chapitre, nous n'allons aborder que les accès en écriture de la VRAM (cas le plus fréquent), mais toutes les contraintes décrites ci-dessous s'applique exactement de la même façon pour la lecture en VRAM.

Remplissage



Après la configuration des registres pour accéder à la VRAM de façon séquentielle (qui n'est pas expliqué ici), le moyen le plus rapide d'écrire 4 octets en VRAM est :

; Ecrire la valeur de A dans 4 octets consécutif en VRAM
out (0x98), a
out (0x98), a
out (0x98), a
out (0x98), a


L'instruction out (n),a prenant 12 cycles sur MSX, elle est trop rapide pour la plus part des modes d'affichage (en dehors du V-Blank).

L'instruction out (c),a (ou le numéro de port I/O se trouve dans le registre C) prend elle 14 cycles sur MSX. Ce qui est un chouilla trop rapide pour la plupart des modes d'affichage. Ajouter un nop entre chaque out (c),a permet d'arriver à 15 cycles, l'intervalle minimale d'accès à la VRAM des modes bitmap du MSX2 (comme le Screen 5 par ex.).

Par contre, cette solution est encore trop rapide pour les modes graphiques sur MSX1 (29 cycles) ou les modes textes sur MSX2 (20 cycles). Dans ces cas, le mieux est d'intercaler d'autres instructions entre les out pour les écarter suffisamment dans le temps, sans perdre du temps CPU à ne rien faire avec une longue série de nop .

Code optimal



Codes les plus rapides pour remplir la VRAM avec une valeur donnée en fonction des mode d'affichages :
Screen 0 MSX1 Screen 3 MSX1 Screen 1~8 MSX2 Screen 0 MSX2 Screen 1~2 MSX1
; 12 cycles
   out (0x98),a
   out (0x98),a
   out (0x98),a
   out (0x98),a
        
; 13 cycles
   out (0x98),a
   nop
   out (0x98),a
   nop
   out (0x98),a
   nop
   out (0x98),a
        
; 15 cycles
   ld c, 0x98
   out (c),a
   nop
   out (c),a
   nop
   out (c),a
   nop
   out (c),a
        
; 20 cycles
   ld c, 0x98
   out (c),a
   nop
   nop
   nop
   nop
   nop
   nop
   out (c),a
   nop
   nop
   nop
   nop
   nop
   nop
   out (c),a
   nop
   nop
   nop
   nop
   nop
   nop
   out (c),a
        
; 29 cycles
   ld c, 0x98
   ld b, #4
loop:
   out (c),a
   nop
   djnz loop
        


Si le nombre de donnée à envoyer est variable ou si la taille du programme est primordiale, la boucle la plus rapide pour remplir la VRAM fonctionne avec un intervalle de 26 cycles et est donc compatible avec la plupart des modes d'affichage :

; 26 cycles
   ld b, #100
loop:
   out (0x98),a
   djnz loop


Cette boucle permet d'écrire jusqu'à 256 octets. Pour plus, il faut soit créer une double boucle (boucle 16-bits, qui permet jusqu'à 65536 itérations) ou utiliser un boucle dépliée (cf. le chapitre dédié).

Copie



Les exemples précédent ne permettent que de remplir une zone de VRAM avec une valeur unique. C'est utile pour effacer une zone de VRAM par ex., mais la plus part du temps, le programme va vouloir envoyer des données graphiques qui viennent de la mémoire du CPU (un image, un sprite, etc.) et il faut donc utiliser les instructions Z80 qui le permettent.

outi et outd permettent d'envoyer vers le VDP la valeur contenu à l'adresse pointé par le registre HL puis incrémente/décréemente HL.

Une fois avoir configuré HL pour pointer vers ses données en mémoire et les registres du VDP pour pointer vers la destination dans la VRAM, le moyen le plus rapide de transférer des données est :

; Ecrire la valeur de (HL++) dans 4 octets consécutif en VRAM
outi
outi
outi
outi


L'instruction outi prend 18 cycles, ce qui est suffisant pour tous les modes sauf les modes graphiques sur MSX1 (29 cycles) ou les modes textes sur MSX2 (20 cycles).
outir prend 23 cycles mais permet de choisir un nombre variable d'accès à la VRAM avec une seule instruction (le nombre d'itération se trouvant dans le registre B).

Pour les modes textes sur MSX2, il suffit d'ajouter 2 nop entre chaque outi pour respecter l'intervalle de temps minimale d'accès.

outir ne peut être "ralenti" avec des nop vu que la boucle est géré en interne par le Z80.
Pour avoir une boucle qui respecte l'intervalle de 29 cycles nécessaires pour les modes graphiques sur MSX1, vous pouvez utiliser :

; Boucle de 29 cycles (18 + 11)
OutiToVram:
    outi              ; send (HL) ; HL++ ; B--
    jp nz, OutiToVram ; while register B not 0


Code optimal



Codes les plus rapides pour copier des données en VRAM fonction des mode d'affichages (adresse des données à copier en mémoire dans le registre HL) :
Screen 0 et 3 MSX1
Screen 1~8 MSX2
Screen 0 MSX2 Screen 1~2 MSX1
; 18 cycles
   ld c, 0x98
   outi
   outi
   outi
   outi
        
; 20 cycles
   ld c, 0x98
   outi
   nop
   nop
   outi
   nop
   nop
   outi
   nop
   nop
   outi
        
; 29 cycles
   ld c, 0x98
   ld b, 4
loop:
    outi
    jp nz, loop
        


Temps d'exécution des instructions de I/O



Instruction Cycles (T-States) Timing I/O
in a,(n) 12 9|3
out (n),a 12 9|3
in a,(c) 14 11|3
out (c),a 14 11|3
outi 18 15|3
outd 18 15|3
ini 18 12|6
ind 18 12|6
otir 23 (loop)
18 (last)
15|8
15|3
otdr 23 (loop)
18 (last)
15|8
15|3
inir 23 (loop)
18 (last)
12|11
12|6
indr 23 (loop)
18 (last)
12|11
12|6


Tant qu'un programme n'enchaine qu'une même instruction à intervalle régulier, seule le temps d'exécution de cette instruction compte. Par contre, si on enchaine des instructions différentes, il est important de savoir à quel cycle de l'exécution de l'instruction l'I/O a lieu. Voir la dernière colonne du tableau ci-dessus. Les timing sont définies sous la forme X | Y avec X = nombre de cycle avant l'I/O et Y = nombre de cycle entre après l'I/O.

Par ex., si on enchaine les instructions outi (15|3) et ini (12|6), l'intervalle de temps entre deux I/O est alors de 15 cycles (3 + 12).

Boucle déroulé



La plupart du temps, un programme va vouloir remplir ou copier un grand nombre de donnée en VRAM. La méthode la plus simple pour dépasser la limite des 256 octets d'une boucle classique est de créer une double boucle (décrémentation automatique du registre B via djnz et manuelle d'un autre registre). Cette méthode (boucle 16-bits) est presque aussi rapide qu'une boucle 8-bits puisse que le surcout de la 2e boucle (16 cycles) est amortie par le fait qu'elle n'est exécuté qu'une fois tous les 256 octets.

L'instruction otir est plus rapide (23 cycles) mais également limité à 256 octets (limite qu'on peut également dépasser avec un double boucle 16-bits).

Mais il y a une méthode qui allie vitesse et grande quantité de donnée : Il s'agit de « dérouler » une partie de la boucle.
Par ex., on peut avoir 16 instructions d'I/O consécutive dans une boucle et ainsi diviser le nombre d'itération de la boucle par 16.

Le temps d'initialisation de la boucle sera plus lent, mais les itérations beaucoup plus rapide (gain ~18%).
Il y a donc un nombre minimal d'itération à partir duquel cette méthode devient rentable.
Pour une centaine d'octet à envoyer au VDP, cette méthode est déjà très largement rentable.

Dans l'exemple suivant, la première itération peut couter jusqu'à 130 cycles dans le pire des cas, mais pour un grand nombre de donnée, la vitesse tend assez rapidement vers des accès I/O de 18,7 cycles !

        ; HL: Source address in RAM
        ; DE: Destination address in VRAM
        ; BC: Bytes to copy (4096 max in this example)
        di
        ; Setup destination address ...
        ; ... in VRAM
    fastcopy_setup:
        ; Setup fast OUTI loop
        xor     a                       ;  5 cc
        sub     c                       ;  5 cc
        and     #15                     ;  8 cc
        jp      z, fastcopy_loop        ; 11 cc - total 29 cc (break-even at 16 loops)
        add     a                       ;  5 cc
        exx                             ;  5 cc
        add     a, #fastcopy_loop       ;  8 cc
        ld      l, a                    ;  5 cc
        ld      a, #0                   ;  8 cc
        adc     a, #fastcopy_loop >> 8  ;  8 cc
        ld      h, a                    ;  5 cc
        push    hl                      ; 12 cc
        exx                             ;  5 cc
        ret                             ; 11 cc - total 101 cc (break-even at 25 loops)
    fastcopy_loop:
        ; Fast OUTIR (with 16x unrolled OUTI)
        outi                            ; 18 cc
        outi                            ; 18 cc
        outi                            ; 18 cc
        outi                            ; 18 cc
        outi                            ; 18 cc
        outi                            ; 18 cc
        outi                            ; 18 cc
        outi                            ; 18 cc
        outi                            ; 18 cc
        outi                            ; 18 cc
        outi                            ; 18 cc
        outi                            ; 18 cc
        outi                            ; 18 cc
        outi                            ; 18 cc
        outi                            ; 18 cc
        outi                            ; 18 cc
        jp      pe, fastcopy_loop       ; 11 cc (0,6875 cc per outi)
    fastcopy_end:
        ei


La seule astuce de ce code, c'est que la première itération de la boucle ne commence pas à fastcopy_loop si le nombre d'octet à copier n'est pas multiple de 16.
Dans ce cas, on calcul l'adresse à laquelle doit commencer la première itération. Par exemple, si on veut copier 1030 octets (1024 + 6), la première itération sautera à la 10e instruction outi pour que la première boucle n'est fasse que 6, puis les suivantes se feront toutes 16 octets par 16 octets.