Temps d'accès à la VRAM
Table des matières
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 assez lente : 45 cycles pour une copie.
Le meilleur moyen d'allier vitesse et grande quantité de donnée est de « dérouler » une partie de la boucle.
Par ex., on peut avoir 16 instructions d'I/O consécutive dans une boucle et 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.
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.
; HL: Source address in RAM ; DE: Destination address in VRAM ; BC: Bytes to copy 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