Wikilivres frwikibooks https://fr.wikibooks.org/wiki/Accueil MediaWiki 1.46.0-wmf.24 first-letter Média Spécial Discussion Utilisateur Discussion utilisateur Wikilivres Discussion Wikilivres Fichier Discussion fichier MediaWiki Discussion MediaWiki Modèle Discussion modèle Aide Discussion aide Catégorie Discussion catégorie Transwiki Discussion Transwiki Wikijunior Discussion Wikijunior TimedText TimedText talk Module Discussion module Event Event talk Apache/HTTPS 0 13020 765141 753097 2026-04-26T18:06:57Z JackPotte 5426 /* Autres aspects de sécurité */ 765141 wikitext text/x-wiki <noinclude>{{Apache}}</noinclude> == Généralités == Contrairement au protocole HTTP, {{w|HTTPS}} garantit la confidentialité et l'intégrité des données échangées entre un serveur web et ses clients, et par conséquent il convient mieux aux transactions sensibles comme les flux bancaires. En effet il permet de se prémunir de l'{{w|attaque de l'homme du milieu}} en cryptant les communications. Pour mettre en place ce protocole, il faut juste activer l'extension Apache et ajouter une directive pour le port 443<ref>https://www.startssl.com/?app=21</ref> avec un {{w|certificat électronique}}. == Types de clé == Un certificat électronique (.crt) est issu d'une {{w|Demande de signature de certificat|demande d'identification}} (.csr pour {{lang|en|Certificate Signing Request}}<ref>https://www.isicca.info/certificat-ssl-generer-son-certificat/</ref>). Ce dernier est généralement payant, à renouveler chaque année, car délivré par une autorité de certification, mais : [[Image:Firefox - cette connexion n'est pas certifiée.PNG|vignette|upright=2|Avertissement à accepter par les visiteurs en cas de certificat autosigné.]] * Il est possible de le créer soi-même<ref>http://doc.ubuntu-fr.org/tutoriel/comment_creer_un_certificat_ssl</ref>, ce qui aura pour effet d'afficher un avertissement d'exception de sécurité aux visiteurs comme celui de l'image ci-contre. D'ailleurs sur Ubuntu, il existe déjà <u>/etc/ssl/private/ssl-cert-snakeoil.key</u>, mais l'avertissement sera le même. Pour en générer une nouvelle du même type, se reporter aux paragraphes ci-après. * Certains sites comme {{w|GeoTrust|lang=en}} en propose un valide, mais valable seulement 30 jours<ref>https://www.ssl247.fr/certificat-ssl-gratuit</ref>. * La meilleure solution gratuite est {{w|Let's Encrypt}}<ref>https://letsencrypt.org/</ref>, car elle permet de créer et configurer (ou renouveler) des sites HTTPS en une minute seulement, avec possibilité d'un renouvellement automatique par cron, grâce à https://certbot.eff.org/. == Linux == === Mod SSL === Ajouter le module SSL à Apache 2<ref>http://doc.ubuntu-fr.org/tutoriel/securiser_apache2_avec_ssl</ref> : {{Cadre code|commande nécessitant les privilèges root|<code># a2enmod ssl</code>}} Ajouter ''Listen 443'' à ''/etc/apache2/ports.conf'' {{Cadre code|commande nécessitant les privilèges root|<code># echo "Listen 443" >> /etc/apache2/ports.conf</code>}} Générer un certificat autosigné : {{Cadre code|commande nécessitant les privilèges root|<code># apache2-ssl-certificate</code>}} Si la commande est introuvable : {{Cadre code|commande nécessitant les privilèges root| <pre> # apt-get install ssl-cert # /usr/sbin/make-ssl-cert /usr/share/ssl-cert/ssleay.cnf /etc/apache2/ssl/apache.pem </pre>}} {{remarque|Cette étape peut aussi être réalisée avec [[OpenSSL]].}} On configure un site en SSL : sudo cp /etc/apache2/sites-available/default /etc/apache2/sites-available/ssl sudo ln -s /etc/apache2/sites-available/ssl /etc/apache2/sites-enabled/ssl Éditez le fichier de configuration /etc/apache2/sites-enabled/ssl pour qu'il accepte les connexions sur le port 443 : NameVirtualHost *:443 <VirtualHost *:443> (...les répertoires et autres configurations si désiré) Éditez le fichier de configuration /etc/apache2/sites-available/default pour qu'il accepte les connexions sur le port 80 : NameVirtualHost *:80 <VirtualHost *:80> (...les répertoires et autres configurations si désiré) et dans le milieu du fichier /etc/apache2/sites-available/ssl ajoutez : SSLEngine On SSLCertificateFile /etc/apache2/ssl/apache.pem Puis redémarrez apache : sudo /etc/init.d/apache2 restart Pour rendre possible la connexion en SSL, la configuration Apache suivante : <syntaxhighlight lang=apache> vim /etc/apache2/apache2.conf # ou vim /etc/apache2/sites-available/default-ssl a2ensite default-ssl </syntaxhighlight> doit comprendre dans chaque vhost concerné : {{Cadre fichier|le fichier de configuration| <syntaxhighlight lang=Apache> <VirtualHost *:443> SSLEngine on #SSLCertificateFile /etc/ssl/certs/ssl-cert-snakeoil.pem #SSLCertificateKeyFile /etc/ssl/private/ssl-cert-snakeoil.key SSLCertificateFile /etc/apache2/ssl/apache.crt SSLCertificateKeyFile /etc/apache2/ssl/apache.key ... </syntaxhighlight> }} Puis on relance Apache : <syntaxhighlight lang="bash"> service apache2 reload </syntaxhighlight> Pour tester : <syntaxhighlight lang="bash"> curl https://monURL </syntaxhighlight> Si la clé nécessite un mot de passe : <syntaxhighlight lang=Apache> SSLPassPhraseDialog exec:/etc/ssl/nomdedomaine.fr.pwd </syntaxhighlight> ==== Personnalisation ==== Pour personnaliser les directives (ex : ajouter ''SuexecUserGroup''), il suffit de copier le contenu de : vim /etc/apache2/apache2.conf dans vim /etc/apache2/sites-available/default-ssl.conf en remplaçant <code>:80</code> par <code>:443</code>. == Windows == === Prérequis === Ajouter le module SSL en décommentant la ligne suivante de <code>httpd.conf</code> : #LoadModule ssl_module modules/mod_ssl.so La directive sur le port 443 s'effectue ensuite dans le fichier suivant (vide par défaut), selon le logiciel : * <code>C:\Program Files (x86)\EasyPHP\binaries\apache\conf\inc_virtual_hosts.conf</code> * <code>C:\Program Files (x86)\WAMP\bin\apache\Apache2.2.21\conf\extra\httpd-vhosts.conf</code> Mais <code>httpd.conf</code> contient un commentaire avec : <code>#<VirtualHost _default_:443></code>. Il faut y renseigner l'emplacement du <code>ssl.crt</code> ci-dessous à créer avant de décommenter, sous peine d'erreur Apache. === Création du certificat autosigné === Lancer une console DOS : <syntaxhighlight lang=dos> >cd "C:\Program Files (x86)\EasyPHP\binaries\apache\bin" >openssl req -config "C:\Program Files (x86)\EasyPHP\binaries\php\php_runningversion\extras\ssl\openssl.cnf" -new -out Certificat_1.csr WARNING: can't open config file: c:/openssl-1.0.1e/ssl/openssl.cnf Loading 'screen' into random state - done Generating a 1024 bit RSA private key ..........++++++ .........++++++ writing new private key to 'privkey.pem' Enter PEM pass phrase: ... >openssl rsa -in privkey.pem -out Certificat_1.key >openssl x509 -in Certificat_1.csr -out Certificat_1.cert -req -signkey Certificat_1.key -days 365 </syntaxhighlight> Cela a généré les fichiers à renseigner dans la directive : * <code>C:\Program Files (x86)\EasyPHP\binaries\apache\bin\Certificat_1.csr</code>. * <code>C:\Program Files (x86)\EasyPHP\binaries\apache\bin\Certificat_1.key</code>. * <code>C:\Program Files (x86)\EasyPHP\binaries\apache\bin\Certificat_1.cert</code>. Le CSR n'a pas besoin d'être renseigné dans les directives, le certificat fichier expire après 365 jours<ref>http://www.finalclap.com/faq/414-certificat-ssl-https</ref>. == Autres aspects de sécurité == Le module <code>headers</code><ref>https://httpd.apache.org/docs/trunk/fr/mod/mod_headers.html</ref> peut assurer la protection contre le {{w|XSS}}<ref>http://content-security-policy.com/</ref> et le {{w|clickjacking}}. Exemple : <syntaxhighlight lang=apache> <IfModule mod_headers.c> # Anti exploit 0 day Header set Server "WebServer" # Anti XSS Header always set X-XSS-Protection "1; mode=block" # Anti clickjacking Header always set X-FRAME-OPTIONS "SAMEORIGIN" # Anti cookie stealer Header edit Set-Cookie ^(.*)$ $1;HttpOnly;Secure # Anti usurpation via iframe Header set Content-Security-Policy "frame-ancestors 'self';" # Anti MIME-type sniffing Header always set X-Content-Type-Options "nosniff" # Limite d'un an à l'HTTPS Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" </IfModule> </syntaxhighlight> {{attention|Cela bloque les iFrames.}} Pour tester une configuration, mieux vaut commencer par un .html plutôt que de la déployer sur tout le serveur en le redémarrant puis de faire machine arrière. Si en définissant la règle la plus permissive Firefox bloque tout de même quelque chose ({{rouge|Content Security Policy: Les paramètres de la page ont empêché le chargement d'une ressource à self}} ), il s'agit d'un bug connu qui n'affecte pas les autres navigateurs : <syntaxhighlight lang=html5> <head> <meta http-equiv="Content-Security-Policy" content="default-src *"> </head> </syntaxhighlight> Par ailleurs, des sites d'audit gratuits peuvent ensuite révéler s'il reste des failles. == Rediriger le flux HTTP vers HTTPS == Entrer la configuration suivante dans le fichier ''https.conf'' : <syntaxhighlight lang="apacheconf" file="https.conf"> RewriteEngine On RewriteCond %{HTTPS} off RewriteRule (.*) https://%{HTTP_HOST}%{REQUEST_URI} </syntaxhighlight> Pour le webhosting, le réglage doit être effectué à l'aide d'un fichier .htaccess, avec la configuration suivante : <syntaxhighlight lang="apacheconf" file=".htaccess"> RewriteEngine On RewriteCond %{HTTPS} off RewriteRule (.*) https://%{HTTP_HOST}%{REQUEST_URI} </syntaxhighlight> == Références == {{Références}} * https://commaster.net/content/how-setup-lets-encrypt-apache-windows 9mq06clon0jtuo0etaonsjt6fc7h8t7 765142 765141 2026-04-26T18:07:14Z JackPotte 5426 /* Autres aspects de sécurité */ 765142 wikitext text/x-wiki <noinclude>{{Apache}}</noinclude> == Généralités == Contrairement au protocole HTTP, {{w|HTTPS}} garantit la confidentialité et l'intégrité des données échangées entre un serveur web et ses clients, et par conséquent il convient mieux aux transactions sensibles comme les flux bancaires. En effet il permet de se prémunir de l'{{w|attaque de l'homme du milieu}} en cryptant les communications. Pour mettre en place ce protocole, il faut juste activer l'extension Apache et ajouter une directive pour le port 443<ref>https://www.startssl.com/?app=21</ref> avec un {{w|certificat électronique}}. == Types de clé == Un certificat électronique (.crt) est issu d'une {{w|Demande de signature de certificat|demande d'identification}} (.csr pour {{lang|en|Certificate Signing Request}}<ref>https://www.isicca.info/certificat-ssl-generer-son-certificat/</ref>). Ce dernier est généralement payant, à renouveler chaque année, car délivré par une autorité de certification, mais : [[Image:Firefox - cette connexion n'est pas certifiée.PNG|vignette|upright=2|Avertissement à accepter par les visiteurs en cas de certificat autosigné.]] * Il est possible de le créer soi-même<ref>http://doc.ubuntu-fr.org/tutoriel/comment_creer_un_certificat_ssl</ref>, ce qui aura pour effet d'afficher un avertissement d'exception de sécurité aux visiteurs comme celui de l'image ci-contre. D'ailleurs sur Ubuntu, il existe déjà <u>/etc/ssl/private/ssl-cert-snakeoil.key</u>, mais l'avertissement sera le même. Pour en générer une nouvelle du même type, se reporter aux paragraphes ci-après. * Certains sites comme {{w|GeoTrust|lang=en}} en propose un valide, mais valable seulement 30 jours<ref>https://www.ssl247.fr/certificat-ssl-gratuit</ref>. * La meilleure solution gratuite est {{w|Let's Encrypt}}<ref>https://letsencrypt.org/</ref>, car elle permet de créer et configurer (ou renouveler) des sites HTTPS en une minute seulement, avec possibilité d'un renouvellement automatique par cron, grâce à https://certbot.eff.org/. == Linux == === Mod SSL === Ajouter le module SSL à Apache 2<ref>http://doc.ubuntu-fr.org/tutoriel/securiser_apache2_avec_ssl</ref> : {{Cadre code|commande nécessitant les privilèges root|<code># a2enmod ssl</code>}} Ajouter ''Listen 443'' à ''/etc/apache2/ports.conf'' {{Cadre code|commande nécessitant les privilèges root|<code># echo "Listen 443" >> /etc/apache2/ports.conf</code>}} Générer un certificat autosigné : {{Cadre code|commande nécessitant les privilèges root|<code># apache2-ssl-certificate</code>}} Si la commande est introuvable : {{Cadre code|commande nécessitant les privilèges root| <pre> # apt-get install ssl-cert # /usr/sbin/make-ssl-cert /usr/share/ssl-cert/ssleay.cnf /etc/apache2/ssl/apache.pem </pre>}} {{remarque|Cette étape peut aussi être réalisée avec [[OpenSSL]].}} On configure un site en SSL : sudo cp /etc/apache2/sites-available/default /etc/apache2/sites-available/ssl sudo ln -s /etc/apache2/sites-available/ssl /etc/apache2/sites-enabled/ssl Éditez le fichier de configuration /etc/apache2/sites-enabled/ssl pour qu'il accepte les connexions sur le port 443 : NameVirtualHost *:443 <VirtualHost *:443> (...les répertoires et autres configurations si désiré) Éditez le fichier de configuration /etc/apache2/sites-available/default pour qu'il accepte les connexions sur le port 80 : NameVirtualHost *:80 <VirtualHost *:80> (...les répertoires et autres configurations si désiré) et dans le milieu du fichier /etc/apache2/sites-available/ssl ajoutez : SSLEngine On SSLCertificateFile /etc/apache2/ssl/apache.pem Puis redémarrez apache : sudo /etc/init.d/apache2 restart Pour rendre possible la connexion en SSL, la configuration Apache suivante : <syntaxhighlight lang=apache> vim /etc/apache2/apache2.conf # ou vim /etc/apache2/sites-available/default-ssl a2ensite default-ssl </syntaxhighlight> doit comprendre dans chaque vhost concerné : {{Cadre fichier|le fichier de configuration| <syntaxhighlight lang=Apache> <VirtualHost *:443> SSLEngine on #SSLCertificateFile /etc/ssl/certs/ssl-cert-snakeoil.pem #SSLCertificateKeyFile /etc/ssl/private/ssl-cert-snakeoil.key SSLCertificateFile /etc/apache2/ssl/apache.crt SSLCertificateKeyFile /etc/apache2/ssl/apache.key ... </syntaxhighlight> }} Puis on relance Apache : <syntaxhighlight lang="bash"> service apache2 reload </syntaxhighlight> Pour tester : <syntaxhighlight lang="bash"> curl https://monURL </syntaxhighlight> Si la clé nécessite un mot de passe : <syntaxhighlight lang=Apache> SSLPassPhraseDialog exec:/etc/ssl/nomdedomaine.fr.pwd </syntaxhighlight> ==== Personnalisation ==== Pour personnaliser les directives (ex : ajouter ''SuexecUserGroup''), il suffit de copier le contenu de : vim /etc/apache2/apache2.conf dans vim /etc/apache2/sites-available/default-ssl.conf en remplaçant <code>:80</code> par <code>:443</code>. == Windows == === Prérequis === Ajouter le module SSL en décommentant la ligne suivante de <code>httpd.conf</code> : #LoadModule ssl_module modules/mod_ssl.so La directive sur le port 443 s'effectue ensuite dans le fichier suivant (vide par défaut), selon le logiciel : * <code>C:\Program Files (x86)\EasyPHP\binaries\apache\conf\inc_virtual_hosts.conf</code> * <code>C:\Program Files (x86)\WAMP\bin\apache\Apache2.2.21\conf\extra\httpd-vhosts.conf</code> Mais <code>httpd.conf</code> contient un commentaire avec : <code>#<VirtualHost _default_:443></code>. Il faut y renseigner l'emplacement du <code>ssl.crt</code> ci-dessous à créer avant de décommenter, sous peine d'erreur Apache. === Création du certificat autosigné === Lancer une console DOS : <syntaxhighlight lang=dos> >cd "C:\Program Files (x86)\EasyPHP\binaries\apache\bin" >openssl req -config "C:\Program Files (x86)\EasyPHP\binaries\php\php_runningversion\extras\ssl\openssl.cnf" -new -out Certificat_1.csr WARNING: can't open config file: c:/openssl-1.0.1e/ssl/openssl.cnf Loading 'screen' into random state - done Generating a 1024 bit RSA private key ..........++++++ .........++++++ writing new private key to 'privkey.pem' Enter PEM pass phrase: ... >openssl rsa -in privkey.pem -out Certificat_1.key >openssl x509 -in Certificat_1.csr -out Certificat_1.cert -req -signkey Certificat_1.key -days 365 </syntaxhighlight> Cela a généré les fichiers à renseigner dans la directive : * <code>C:\Program Files (x86)\EasyPHP\binaries\apache\bin\Certificat_1.csr</code>. * <code>C:\Program Files (x86)\EasyPHP\binaries\apache\bin\Certificat_1.key</code>. * <code>C:\Program Files (x86)\EasyPHP\binaries\apache\bin\Certificat_1.cert</code>. Le CSR n'a pas besoin d'être renseigné dans les directives, le certificat fichier expire après 365 jours<ref>http://www.finalclap.com/faq/414-certificat-ssl-https</ref>. == Autres aspects de sécurité == Le module <code>headers</code><ref>https://httpd.apache.org/docs/trunk/fr/mod/mod_headers.html</ref> peut assurer la protection contre le {{w|XSS}}<ref>http://content-security-policy.com/</ref> et le {{w|clickjacking}}. Exemple : <syntaxhighlight lang=apache> <IfModule mod_headers.c> # Anti exploit 0 day Header set Server "WebServer" # Anti XSS Header always set X-XSS-Protection "1; mode=block" # Anti clickjacking Header always set X-FRAME-OPTIONS "SAMEORIGIN" # Anti cookie stealer Header edit Set-Cookie ^(.*)$ $1;HttpOnly;Secure # Anti usurpation via iframe Header set Content-Security-Policy "frame-ancestors 'self';" # Anti MIME-type sniffing Header always set X-Content-Type-Options "nosniff" # Limite d'un an à l'HTTPS Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" </IfModule> </syntaxhighlight> {{attention|Cela bloque les iFrames depuis d'autres sites.}} Pour tester une configuration, mieux vaut commencer par un .html plutôt que de la déployer sur tout le serveur en le redémarrant puis de faire machine arrière. Si en définissant la règle la plus permissive Firefox bloque tout de même quelque chose ({{rouge|Content Security Policy: Les paramètres de la page ont empêché le chargement d'une ressource à self}} ), il s'agit d'un bug connu qui n'affecte pas les autres navigateurs : <syntaxhighlight lang=html5> <head> <meta http-equiv="Content-Security-Policy" content="default-src *"> </head> </syntaxhighlight> Par ailleurs, des sites d'audit gratuits peuvent ensuite révéler s'il reste des failles. == Rediriger le flux HTTP vers HTTPS == Entrer la configuration suivante dans le fichier ''https.conf'' : <syntaxhighlight lang="apacheconf" file="https.conf"> RewriteEngine On RewriteCond %{HTTPS} off RewriteRule (.*) https://%{HTTP_HOST}%{REQUEST_URI} </syntaxhighlight> Pour le webhosting, le réglage doit être effectué à l'aide d'un fichier .htaccess, avec la configuration suivante : <syntaxhighlight lang="apacheconf" file=".htaccess"> RewriteEngine On RewriteCond %{HTTPS} off RewriteRule (.*) https://%{HTTP_HOST}%{REQUEST_URI} </syntaxhighlight> == Références == {{Références}} * https://commaster.net/content/how-setup-lets-encrypt-apache-windows lcb36k0my6u5biahge8bjrfo8otvlrx Wikijunior:Alphabet des fleurs/B 102 30049 765135 684007 2026-04-26T17:38:55Z ~2026-25563-57 123624 765135 wikitext text/x-wiki [[Image:B_is_for_Barleria.jpg|center|500px]] <div style="text-align: center; font-size:3em; margin: 1em 0;">'''B''' comme '''B'''arleria.</div> {{../Bas de page|A|C}} [[Catégorie:Alphabet des fleurs (livre)|B]] h7798nqixqtpfl6lui6a2n7doy2tmdq 765137 765135 2026-04-26T18:02:13Z JackPotte 5426 Révocation d’une modification de [[Special:Contributions/~2026-25563-57|~2026-25563-57]] ([[User talk:~2026-25563-57|discussion]]) vers la dernière version de [[User:DavidL|DavidL]] 684007 wikitext text/x-wiki [[Image:b_bruyère.jpg|center|500px]] <div style="text-align: center; font-size:3em; margin: 1em 0;">'''B''' comme '''B'''ruyère.</div> {{../Bas de page|A|C}} [[Catégorie:Alphabet des fleurs (livre)|B]] 6l4dd95rdn2l5vkk7oc631gr0jaz1vr Wikijunior:Alphabet/R 102 54020 765133 765110 2026-04-26T17:32:31Z ~2026-25563-57 123624 rat 765133 wikitext text/x-wiki <div style="text-align: center; font-size: 400%;">'''R''' comme '''R'''at</div> [[Fichier:Brown rat illustration.png|center|500x500px]] {{../Bas de page|Q|S}} {{Son|1=Un rat|2=Fr-rat.ogg}} [[az:Vikiuşaq:Əlifba/R]] [[en:Wikijunior:Alphabet/R]] [[tr:Vikiçocuk:Alfabe/R]] [[Catégorie:Alphabet (livre)|R]] myzy8agy86jl2909r8g6uydnsjowzj3 765136 765133 2026-04-26T18:02:07Z JackPotte 5426 Révocation d’une modification de [[Special:Contributions/~2026-25563-57|~2026-25563-57]] ([[User talk:~2026-25563-57|discussion]]) vers la dernière version de [[User:JackPotte|JackPotte]] 765110 wikitext text/x-wiki <div style="text-align: center; font-size: 400%;">'''R''' comme '''R'''oute</div> [[Fichier:Road in Norway.jpg|center|500px]] {{../Bas de page|Q|S}} {{Son|1=Route|2=Fr-Route-fr-Paris.ogg}} [[az:Vikiuşaq:Əlifba/R]] [[en:Wikijunior:Alphabet/R]] [[tr:Vikiçocuk:Alfabe/R]] [[Catégorie:Alphabet (livre)|R]] 8gbiqnn3b0n8o2ypterk00q9f51h9x4 765139 765136 2026-04-26T18:05:55Z ~2026-25563-57 123624 rat 765139 wikitext text/x-wiki <div style="text-align: center; font-size: 400%;">'''R''' comme '''R'''at</div> [[Fichier:Brown rat illustration.png|center|500x500px]] {{../Bas de page|Q|S}} {{Son|1=Un rat|2=Fr-rat.ogg}} [[az:Vikiuşaq:Əlifba/R]] [[en:Wikijunior:Alphabet/R]] [[tr:Vikiçocuk:Alfabe/R]] [[Catégorie:Alphabet (livre)|R]] myzy8agy86jl2909r8g6uydnsjowzj3 765171 765139 2026-04-26T21:54:40Z DavidL 1746 Révocation d’une modification de [[Special:Contributions/~2026-25563-57|~2026-25563-57]] ([[User talk:~2026-25563-57|discussion]]) vers la dernière version de [[User:JackPotte|JackPotte]] 765110 wikitext text/x-wiki <div style="text-align: center; font-size: 400%;">'''R''' comme '''R'''oute</div> [[Fichier:Road in Norway.jpg|center|500px]] {{../Bas de page|Q|S}} {{Son|1=Route|2=Fr-Route-fr-Paris.ogg}} [[az:Vikiuşaq:Əlifba/R]] [[en:Wikijunior:Alphabet/R]] [[tr:Vikiçocuk:Alfabe/R]] [[Catégorie:Alphabet (livre)|R]] 8gbiqnn3b0n8o2ypterk00q9f51h9x4 765172 765171 2026-04-26T21:55:03Z DavidL 1746 A protégé « [[Wikijunior:Alphabet/R]] » : Vandalisme excessif ([Modifier=Autoriser uniquement les utilisateurs autoconfirmés] (infini) [Renommer=Autoriser uniquement les utilisateurs autoconfirmés] (infini)) 765110 wikitext text/x-wiki <div style="text-align: center; font-size: 400%;">'''R''' comme '''R'''oute</div> [[Fichier:Road in Norway.jpg|center|500px]] {{../Bas de page|Q|S}} {{Son|1=Route|2=Fr-Route-fr-Paris.ogg}} [[az:Vikiuşaq:Əlifba/R]] [[en:Wikijunior:Alphabet/R]] [[tr:Vikiçocuk:Alfabe/R]] [[Catégorie:Alphabet (livre)|R]] 8gbiqnn3b0n8o2ypterk00q9f51h9x4 Wikijunior:Alphabet/V 102 54029 765134 765108 2026-04-26T17:33:28Z ~2026-25563-57 123624 765134 wikitext text/x-wiki <div style="text-align: center; font-size: 400%;">'''V''' comme '''V'''ache</div> [[Fichier:Cow female black white.jpg|center|500x500px]] {{../Bas de page|U|W}} {{Son|1=Une vache|2=Fr-vache.ogg}} [[az:Vikiuşaq:Əlifba/V]] [[en:Wikijunior:Alphabet/V]] [[tr:Vikiçocuk:Alfabe/V]] [[Catégorie:Alphabet (livre)|V]] 6xshf16bnum4hzhtth2lnnrarxn6epj 765138 765134 2026-04-26T18:02:48Z JackPotte 5426 [[Discussion utilisateur:~2026-25430-78]] : pas de passage en force, ça reste un doublon de l'alphabet des animaux. Annulation de la modification [[Special:Diff/765134|765134]] de [[Special:Contributions/~2026-25563-57|~2026-25563-57]] ([[User talk:~2026-25563-57|discussion]]) 765138 wikitext text/x-wiki <div style="text-align: center; font-size: 400%;">'''V''' comme '''V'''olcan</div> [[Fichier:Augustine Volcano Jan 12 2006.jpg|center|500px]] {{../Bas de page|U|W}} {{Son|1=Un volcan|2=Fr-volcan.ogg}} [[az:Vikiuşaq:Əlifba/V]] [[en:Wikijunior:Alphabet/V]] [[tr:Vikiçocuk:Alfabe/V]] [[Catégorie:Alphabet (livre)|V]] t3ksndys7pwnvvyhz7ldan8od7p762y 765140 765138 2026-04-26T18:06:38Z ~2026-25563-57 123624 vache 765140 wikitext text/x-wiki <div style="text-align: center; font-size: 400%;">'''V''' comme '''V'''ache</div> [[Fichier:Cow female black white.jpg|center|500x500px]] {{../Bas de page|U|W}} {{Son|1=Une vache|2=Fr-vache.ogg}} [[az:Vikiuşaq:Əlifba/V]] [[en:Wikijunior:Alphabet/V]] [[tr:Vikiçocuk:Alfabe/V]] [[Catégorie:Alphabet (livre)|V]] 6xshf16bnum4hzhtth2lnnrarxn6epj 765173 765140 2026-04-26T21:55:59Z DavidL 1746 Révocation d’une modification de [[Special:Contributions/~2026-25563-57|~2026-25563-57]] ([[User talk:~2026-25563-57|discussion]]) vers la dernière version de [[User:JackPotte|JackPotte]] 765138 wikitext text/x-wiki <div style="text-align: center; font-size: 400%;">'''V''' comme '''V'''olcan</div> [[Fichier:Augustine Volcano Jan 12 2006.jpg|center|500px]] {{../Bas de page|U|W}} {{Son|1=Un volcan|2=Fr-volcan.ogg}} [[az:Vikiuşaq:Əlifba/V]] [[en:Wikijunior:Alphabet/V]] [[tr:Vikiçocuk:Alfabe/V]] [[Catégorie:Alphabet (livre)|V]] t3ksndys7pwnvvyhz7ldan8od7p762y 765174 765173 2026-04-26T21:56:28Z DavidL 1746 A protégé « [[Wikijunior:Alphabet/V]] » : Vandalisme excessif ([Modifier=Autoriser uniquement les utilisateurs autoconfirmés] (infini) [Renommer=Autoriser uniquement les utilisateurs autoconfirmés] (infini)) 765138 wikitext text/x-wiki <div style="text-align: center; font-size: 400%;">'''V''' comme '''V'''olcan</div> [[Fichier:Augustine Volcano Jan 12 2006.jpg|center|500px]] {{../Bas de page|U|W}} {{Son|1=Un volcan|2=Fr-volcan.ogg}} [[az:Vikiuşaq:Əlifba/V]] [[en:Wikijunior:Alphabet/V]] [[tr:Vikiçocuk:Alfabe/V]] [[Catégorie:Alphabet (livre)|V]] t3ksndys7pwnvvyhz7ldan8od7p762y Fonctionnement d'un ordinateur/Les mémoires cache 0 65957 765114 762325 2026-04-26T14:32:31Z Mewtow 31375 /* L'exemple des processeurs Intel de microarchitecture Broadwell */ 765114 wikitext text/x-wiki Le cache est une mémoire intercalée entre la mémoire et un processeur, plus rarement à l'intérieur d'un périphérique. Il est souvent fabriquée avec de la mémoire SRAM, parfois avec de l'eDRAM. Sans lui, on se croirait à l'âge de pierre tellement nos PC seraient lents ! En effet, la mémoire est très lente comparée au processeur. Le temps mis pour accéder à la mémoire est du temps durant lequel le processeur n'exécute pas d'instruction (sauf cas particuliers impliquant un pipeline). Pour diminuer ce temps d'attente, il a été décidé d'intercaler une mémoire petite mais rapide, entre le processeur et la mémoire. Ainsi, le processeur accède à un cache très rapide plutôt qu'à une RAM beaucoup plus lente. ==L'accès au cache== Le cache contient une copie de certaines données présentes en RAM. La copie présente dans le cache est accessible bien plus rapidement que celle en RAM, vu que le cache est plus rapide. Mais seule une petite partie de ces données sont copiées dans le cache, les autres données devant être lues ou écrites dans la RAM. Toujours est-il que le cache contient une copie des dernières données accédées par le processeur. Une donnée est copiée dans la mémoire cache quand elle est lue ou écrite par le processeur. Le processeur conserve une copie de la donnée dans le cache après son premier accès. Les lectures/écritures suivantes se feront alors directement dans le cache. Évidemment, au fur et à mesure des accès, certaines données anciennes sont éliminées du cache pour faire de la place aux nouveaux entrants, comme nous le verrons plus tard. [[File:Principe d'une mémoire cache.gif|centre|vignette|upright=2|Principe d'une mémoire cache.]] La mémoire cache est invisible pour le programmeur, qui ne peut pas déceler celles-ci dans l'assembleur. Les accès mémoire se font de la même manière avec ou sans le cache. La raison à cela est que le cache intercepte les accès mémoire et y répond s'il en a la capacité. Par exemple, si le cache intercepte une lecture à une adresse et que le contenu de cette adresse est dans le cache, le cache va outrepasser la mémoire RAM et la donnée sera envoyée par le cache au lieu d'être lue en RAM. par contre, si un accès se fait à une adresse pour laquelle le cache n'a pas la donnée, alors l'accès mémoire sera effectué par la RAM de la même manière que si le cache n'était pas là. [[File:Accès au cache.png|centre|vignette|upright=2|Accès au cache]] ===Les succès et défauts de caches=== Tout accès mémoire est intercepté par le cache, qui vérifie si la donnée demandée est présente ou non dans le cache. Si la donnée voulue est présente dans le cache, on a un '''succès de cache''' (''cache hit'') et on accède à la donnée depuis le cache. Sinon, c'est un '''défaut de cache''' (''cache miss'') et on est obligé d’accéder à la RAM. Les défauts de cache peuvent avoir plusieurs origines. Tout ce qu'il faut savoir est que lorsque le processeur accède à une donnée ou une instruction pour la première fois, il la place dans la mémoire cache car elle a de bonnes chances d'être réutilisée prochainement. La raison à cela est qu'un programme a tendance à réutiliser les instructions et données qui ont été accédées dans le passé : c'est le ''principe de localité temporelle''. Bien évidement, cela dépend du programme, de la façon dont celui-ci est programmé et accède à ses données et du traitement qu'il fait, mais c'est souvent vrai en général. La première cause des défauts de cache est liée à la taille du cache. À force de charger des données/instructions dans le cache, le cache fini par être trop petit pour conserver les anciennes données. Le cache doit bien finir par faire de la place en supprimant les anciennes données, qui ont peu de chances d'être réutilisées. Ces anciennes données éliminées du cache peuvent cependant être accédées plus tard. Tout prochain accès à cette donnée mènera à un cache miss. C'est ce qu'on appelle un ''Capacity Cache Miss'', ou encore '''défaut de capacité'''. Les seules solutions pour éviter cela consistent à augmenter la taille du cache ou à optimiser le programme exécuté (voir plus bas). Une autre raison pour un défaut est donc la suivante. Lorsqu'on exécute à une instruction ou qu'on accède à donnée pour la première fois, celle-ci n'a pas encore été chargée dans le cache. Le défaut de cache est inévitable : ce genre de cache miss s'appelle un ''Cold Miss'', ou encore un '''défaut à froid'''. De tels défauts sont presque impossibles à éliminer, sauf à utiliser des techniques de préchargement qui chargent à l'avance des données potentiellement utiles. Ces méthodes de préchargement se basent sur le principe de localité spatiale, à savoir le fait que les programmes ont tendance à accéder à des données proches en mémoire. Pour donner un exemple, les instructions d'un programme sont placées en mémoire dans l’ordre dans lequel on les exécute : la prochaine instruction à exécuter est souvent placée juste après l'instruction en cours (sauf avec les branchements). Quand on accède à une donnée ou une instruction, le cache peut précharger les données adjacentes pour en profiter. Nous parlerons de ces techniques de préchargement dans un chapitre dédié, vers la fin du cours. ===Le fonctionnement du cache, vu du processeur=== Vu du processeur, le cache prend en entrée toutes les informations nécessaires pour effectuer un accès mémoire : des signaux de commande, une adresse et la donnée à écrire si besoin. Tout cela est passé en entrée du cache, celui-ci répondant aux accès mémoire via divers bits de contrôles, que le processeur peut lire à souhait. Le cache fournit aussi la donnée à lire, pour les lectures, sur une sortie, connectée directement au bus mémoire/processeur. Globalement, le cache a une capacité limitée, mais il prend en entrée des adresses complètes. Par exemple, sur un processeur 64 bits, le cache prend en entrée des adresses de 64 bits (sauf si optimisations), même si le cache en question ne fait que quelques mébioctets. Les caches sont souvent des mémoires multiports, surtout sur les processeurs récents. Les caches simple port sont rares, mêmes s'ils existent et ont existé par le passé. les caches double port sont eux plus fréquents, et ont généralement un port d'écriture séparé du port de lecture. Mais les caches récents ont plusieurs ports de lecture/écriture et sont capables de gérer plusieurs accès mémoire simultanés. Les données présentes dans le cache sont (pré)chargées depuis la mémoire, ce qui fait que toute donnée dans le cache est la copie d'une donnée en mémoire RAM. Le cache doit faire la correspondance entre une donnée du cache et l'adresse mémoire correspondante. Du point de vue du fonctionnement, on peut voir le cache comme une sorte de table de correspondance, qui mémorise des données, chacune étant associée à son adresse mémoire. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Cela vaut du point de vue du processeur, le fonctionnement interne du cache étant quelque peu différent selon le cache. Il existe des caches dont le fonctionnement interne est bien celui d'une table de correspondance matérielle, d'autres qui sont beaucoup plus optimisés. [[File:Fonctionnement d'une mémoire associative à correspondance.png|centre|vignette|upright=2|Fonctionnement simplifié d'une mémoire cache : les adresses sont dans la colonne de gauche, les données sont dans la colonne de droite. On voit qu'on envoie l'adresse au cache, que celui-ci répond en renvoyant la donnée associée.]] ==La performance des mémoires caches== L'analyse de la performance des mémoires caches est plus riche pour celle des autres mémoires. Sa performance dépend de beaucoup de paramètres, mais on peut cependant citer les principaux. Les deux premiers sont tout bonnement sa latence et son débit, comme pour n'importe quelle autre mémoire. La latence est plus importante que son débit, car le processeur est généralement plus rapide que le cache et qu'il n'aime pas attendre. Mais le critère le plus important pour un cache est sa capacité à empêcher des accès mémoire, son efficacité. Plus les accès mémoire sont servis par le cache au lieu de la RAM, meilleures seront les performances. Pour résumer, la performance d'un cache est surtout caractérisée par deux métriques : le taux de défaut, qui correspond à l’efficacité du cache, et la latence du cache. ===Le taux de succès/défaut=== Le '''taux de succès''' (hit ratio) est un premier indicateur des performances du cache, mais un indicateur assez imparfait. C'est le pourcentage d'accès mémoire qui ne déclenchent pas de défaut de cache. Plus il est élevé, plus le processeur accède au cache à la place de la RAM et plus le cache est efficace. Certains chercheurs préfèrent utiliser le '''taux de défauts''', à savoir le pourcentage d'accès mémoire qui entraînent un défaut de cache. Plus il est bas, meilleures sont les performances. Le taux de défaut est relié au taux de succès par l'équation <math>T_\text{succes} = 1 - T_\text{defaut}</math>. Par définition, il est égal à : : <math>\text{Taux de défauts de cache} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d’accès mémoires}}</math> Plutôt que de comparer le nombre de défauts/succès de cache au nombre d'accès mémoire, il est aussi possible de diviser le nombre de défauts par le nombre total d'instructions. On obtient alors le '''taux de défauts/succès par instruction''', une autre métrique utile. Par définition, elle est égale à : : <math>\text{Taux de défauts par instruction} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d'instructions}} = \text{Taux de défauts de cache} \times \frac{\text{Nombre d’accès mémoires}}{\text{Nombre d'instructions}}</math> Si certains défauts de cache sont inévitables quel que soit le cache, comme les défauts à froids, mentionnés plus haut, d'autres défauts peuvent être évités en augmentant la capacité du cache. C'est le cas des défauts de capacité qui sont causés par un accès à une donnée qui a été éliminée du cache faute de place. Plus le cache est gros, moins il a de chances d'être rempli, moins il doit rapatrier de données, plus son taux de succès augmente. Mais nous reviendrons sur le lien entre taille du cache et taux de défaut plus bas. Le taux de succès ne dépend pas que du cache, mais aussi de la conception des programmes exécutés. Une bonne utilisation du cache (ainsi que de la mémoire virtuelle) repose sur le programmeur qui doit prendre en compte les principes de localités dès la conception de ses programmes. Par exemple, un programmeur peut parfaitement tenir compte du cache au niveau de son algorithme : on peut citer l'existence des algorithmes ''cache oblivious'', qui sont conçus pour être optimaux quelle que soit la taille du cache. Le programmeur peut aussi choisir ses structures de données de manière à améliorer la localité. Par exemple, un tableau est une structure de donnée respectant le principe de localité spatiale, tandis qu'une liste chaînée ou un arbre n'en sont pas (bien qu'on puisse les implémenter de façon à limiter la casse). D'autres optimisations sont parfois possibles : par exemple, le sens de parcours d'un tableau multidimensionnel peut faire une grosse différence. Cela permet des gains très intéressants pouvant se mesurer avec des nombres à deux ou trois chiffres. Je vous recommande, si vous êtes programmeur, de vous renseigner le plus possible sur les optimisations de code ou algorithmiques qui concernent le cache : il vous suffira de chercher sur Google. Il y a une citation qui résume bien cela, prononcée par un certain Terje Mathisen. Si vous ne le connaissez pas, cet homme est un vieux programmeur (du temps durant lequel on codait encore en assembleur), grand gourou de l’optimisation, qui a notamment travaillé sur le moteur de Quake 3 Arena. {{BlocCitation|Almost all programming can be viewed as an exercise in caching.|auteur=Terje Mathisen}} ===La latence moyenne d'un cache=== Le temps mis pour lire ou écrire une donnée varie en présence d'un cache. Certaines lectures/écritures vont atterrir directement dans le cache (succès) tandis que d'autres devront aller chercher leur contenu en mémoire RAM (défaut de cache). Dans tous les cas, qu'il y ait défaut ou non, le cache sera consulté et mettra un certain temps à répondre, égal au temps de latence du cache. Tous les accès mémoires auront donc une durée au moins égale au temps de latence du cache, qui sera notée <math>T_c</math>. En cas de succès, le cache aura effectué la lecture ou l'écriture, et aucune action supplémentaire n'est requise. Ce qui n'est pas le cas en cas de défaut : le processeur devra aller lire/écrire la donnée en RAM, ce qui prend un temps supplémentaire égal au temps de latence de la mémoire RAM. Un défaut ajoute donc un temps, une pénalité, à l'accès mémoire. Dans ce qui suivra, le temps d'accès à la RAM sera noté <math>T_m</math>. Fort de ces informations, nous pouvons calculer le temps de latence moyen d'un accès mémoire, qui est la somme du temps d'accès au cache (pour tous les accès mémoire), multiplié par le temps lié aux défauts. On a alors : : <math>T = T_c + \text{Taux de défaut} \times T_m</math> On voit que plus le taux de succès est élevé, plus le temps de latence moyen sera bas, et inversement. Ce qui explique l'influence du taux de succès sur les performances du cache, influence assez importante sur les processeurs actuels. De nos jours, le temps que passe le processeur dans les défauts de cache devient de plus en plus un problème au fil du temps, et gérer correctement le cache est une nécessité, particulièrement sur les processeurs multi-cœurs. Il faut dire que la différence de vitesse entre processeur et mémoire est tellement importante que les défauts de cache sont très lents : alors qu'un succès de cache va prendre entre 1 et 5 cycles d'horloge, un cache miss fera plus dans les 400-1000 cycles d'horloge. Tout ce temps sera du temps de perdu que le processeur aura du mal à mitiger. Autant dire que réduire les défauts de cache est beaucoup plus efficace que d'optimiser les calculs effectués par le processeur (erreur courante chez de nombreux programmeurs, notamment débutants). ===L'impact de la taille du cache sur le taux de défaut et la latence=== Il y a un lien entre taille du cache, taux de défaut, débit binaire et latence moyenne. Globalement, plus un cache est gros, plus il est lent. Simple application de la notion de hiérarchie mémoire vue il y a quelques chapitres. Les raisons à cela sont nombreuses, mais nous ne pouvons pas les aborder ici, car il faudrait que nous sachions comment fonctionne un cache et ce qu'il y a à l'intérieur, ce qui sera vu dans la suite du chapitre. Toujours est-il que la latence moyenne d'un cache assez gros est assez importante. De même, le débit binaire d'un cache diminue avec sa taille, mais dans une moindre mesure. Les petits caches ont donc un gros débit binaire et une faible latence, alors que c'est l'inverse pour les gros caches. Une grande capacité de cache améliore le taux de succès, mais cela se fait au détriment de son temps de latence et de son débit, ce qui fait qu'il y a un compromis assez difficile à trouver entre taille du cache, latence et débit. Il peut arriver qu'augmenter la taille du cache augmente son temps d'accès au point d’entraîner une baisse de performance. Par exemple, les processeurs Nehalem d'Intel ont vus leurs performances dans certains jeux vidéos baisser de 2 à 3 %, malgré de nombreuses améliorations architecturales, parce que la latence du cache L1 avait augmentée de 2 cycles d'horloge. Pour avoir une petite idée du compromis à faire, regardons la relation entre taille du cache et taux de défaut. Il existe une relation approximative entre ces deux variables, appelée la '''loi de puissance des défauts de cache'''. Elle donne le nombre total de défaut de cache en fonction de la taille du cache et de deux autres paramètres. Voici cette loi : : <math>\text{Taux de défauts de cache} \approx K \times \text{Taille du cache}^{- \alpha }</math>, avec <math>K</math> et <math>\alpha</math> deux coefficients qui dépendent du programme exécuté. Le coefficient <math>\alpha</math> est généralement compris entre 0.3 et 0.7, guère plus, et varie suivant le programme exécuté. Précisons que cette loi ne marche que si le cache est assez petit par rapport aux données à utiliser. Pour un cache assez gros et des données très petites, la relation précédente est mise en défaut. Pour s'en rendre compte, il suffit d'étudier le cas extrême où toutes les données nécessaires tiennent dans le cache. Dans ce cas, il n'y a qu'un nombre fixe de défauts de cache : autant qu'il faut charger de données dans le cache. Le nombre de défauts de cache observé dans cette situation n'est autre que le coefficient <math>K</math> de la situation précédente, mais il n'y a aucune dépendance entre taux de défaut et taille du cache. L'origine de cette relation s'explique quand on regarde combien de fois chaque donnée est réutilisée lors de l’exécution d'un programme. La plupart des données finissent par être ré-accédées à un moment ou un autre et il se passe un certain temps entre deux accès à une même donnée. Sur la plupart des programmes, les observations montrent que beaucoup de réutilisations de données se font après un temps très court et qu'inversement, peu de ré-accès se font après un temps inter-accès long. Si on compte le nombre de réutilisation qui ont un temps inter-accès bien précis, on retrouve une loi de puissance identique à celle vue précédemment : : <math>\text{Nombre de réaccès avec un temps inter-accès égal à t} \approx K \times t^{- \beta}</math>, avec t le temps moyen entre deux réutilisations. Le coefficient <math>\beta</math> est ici compris entre 1.7 et 1.3. De manière générale, les coefficients <math>\alpha</math> et <math>\beta</math> sont reliés par la relation <math>\alpha = 1 - \beta</math>, ce qui montre qu'il y a un lien entre les deux relations. Précisons cependant que la loi de puissance précédente ne vaut pas pour tous les programmes informatiques, mais seulement pour la plupart d’entre eux. Il n'est pas rare de trouver quelques programmes pour lesquels les accès aux données sont relativement prédictibles et où une bonne optimisation du code fait que la loi de puissance précédente n'est pas valide. La loi de puissance des défauts de cache peut se démontrer à partir de la relation précédente, sous certaines hypothèses. Si un suppose que le cache est assez petit par rapport aux données, alors les deux relations sont équivalentes. L'idée qui se cache derrière la démonstration est que si le temps entre deux accès à une donnée est trop long, alors la donnée accédée aura plus de chance d'être rapatriée en RAM, ce qui cause un défaut de cache. La chance de rapatriement dépend de la taille du cache, un cache plus gros peut conserver plus de données et a donc un temps avant rapatriement plus long. ==Les lignes de cache et leurs tags== Du point de vue du processeur, les lectures et écritures se font mot mémoire par mot mémoire. Un processeur avec des entiers de 64 bits recoit des données de 64 bits de la part du cache, et y écrit des mots de 64 bits. Mais quand on regarde comment sont stockées les données à l'intérieur du cache, les choses sont différentes. ===Les lignes de cache=== Les données sont mémorisées dans le cache par blocs de plusieurs bytes, d'environ 64 à 256 octets chacun, qui portent le nom de '''lignes de cache'''. Les lignes de cache sont l'unité de stockage que l'on trouve à l'intérieur du cache, mais elles servent aussi d'unité de transaction avec la mémoire RAM. Sur les caches actuels, on transfère les données entre le cache et la RAM ligne de cache par ligne de cache, dans la limite de la taille du bus mémoire. Mais d'autres caches plus anciens permettaient de faire des transferts plus fins. C’est-à-dire qu'on pouvait mettre à jour quelques octets dans une ligne de cache sans avoir à la recopier intégralement depuis ou dans la mémoire RAM. En théorie, on pourrait imaginer des caches où les données sont stockées différemment, où l'unité serait le mot mémoire, par exemple. Par exemple, sur un processeur 64 bits, on aurait une ligne de cache de 64 bits. Cela aurait l'avantage de la simplicité : les transferts entre le processeur et la mémoire serait de même taille, l'intérieur du cache ressemblerait à son interface montrée au processeur. Mais cela aurait quelques défauts qui sont compensés par l'organisation en lignes de cache de grande taille. Le premier avantage des lignes de cache est lié à la localité spatiale, la tendance qu'on les programmes à accéder à des données proches les unes des autres. Des accès mémoires consécutifs ont tendance à se faire à des adresses proches, qui ont de bonnes chances d'être dans la même ligne de cache. Et des accès consécutifs à une même ligne de cache sont plus rapides que des accès à deux lignes distinctes. Une autre raison est tout simplement que cela simplifie considérablement la circuiterie du cache. Pour une capacité identique, il vaut mieux avoir peu de lignes de cache assez grosses, que beaucoup de petites lignes de cache. La raison est que les circuits du cache, comme le décodeur, l'encodeur et autres, ont moins de sorties et sont donc plus simples. ===L'alignement des lignes de cache=== Les lignes de cache sont des blocs de plusieurs dizaines à centaines de bytes, dont la taille est presque toujours une puissance de deux. De plus, les lignes de cache sont alignées en mémoire. Nous avions déjà abordé la notion d'alignement mémoire dans un chapitre précédent, mais le concept d'alignement des lignes de cache est quelque peu différent. Quand nous avions parlé d'alignement auparavant, il s'agissait de l'alignement des données manipulées par le processeur, qui faisait partie du jeu d'instruction du processeur. Ici, nous parlons d'un alignement totalement différent, invisible pour le programmeur, sans lien avec le jeu d’instruction. Voyons de quoi il retourne. Concrètement, cela veut dire que du point de vue du cache, la RAM est découpée en blocs qui font la même taille qu'une ligne de cache, aux positions prédéterminées, sans recouvrement entre les blocs. Par exemple, pour un cache dont les lignes de cache font 256 octets, le premier bloc est à l'adresse 0, le second est 256 octets plus loin, c'est à dire à l'adresse 256, le troisième à l'adresse 512, la quatrième à l'adresse 768, etc. Une ligne de cache de 256 octets contiendra une donnée provenant d'un bloc de RAM de 256 octets, dont l'adresse est systématiquement un multiple de 256. Il n'est pas possible qu'une ligne de cache contienne un bloc de 256 octets dont l'adresse du premier octet serait l'adresse 64, ou l'adresse 32, par exemple. En clair, les adresses de ces blocs sont des multiples de la taille de la ligne de cache, de la taille des blocs. Cela rappelle les contraintes d'alignement vues dans le chapitre "Le modèle mémoire : alignement et boutisme", mais appliquées aux lignes de cache. L'alignement des lignes de cache a des conséquences pratiques pour la conception des caches. Notons qu'il est en théorie possible d'avoir des caches dont les lignes de cache ne sont pas alignées, mais cela poserait des problèmes majeurs. Il serait en effet possible qu'une donnée soit présente dans deux lignes de cache à la fois. Par exemple, prenons le cas où une ligne de cache de 256 commence à l'adresse 64 et une autre ligne de cache commence à l'adresse 0. L'adresse 128 serait dans les deux lignes de cache ! Et cela poserait des problèmes lors des lectures, mais encore plus lors des écritures. C'est pour éviter ce genre de problèmes que les lignes de cache sont alignées avec la mémoire RAM dans tous les caches existants. L'alignement des lignes de cache est une chose que les programmeurs doivent parfois prendre en compte quand ils écrivent du code ultra-optimisé, destiné à des programmes demandant des performances extrêmes. Il arrive que les contraintes d'alignement posent des problèmes. Nous avions vu dans le chapitre sur le boutisme et l'alignement qu'il valait mieux gérer l'alignement des variables des structures de données, pour éviter les accès non-alignés avec le bus mémoire. La même chose est possible, mais pour l'alignement avec des lignes de cache. Typiquement, l'idéal est que, pour une structure de donnée, on puisse en mettre un nombre entier dans une ligne de cache. Ou alors, si la structure est vraiment grande, que celle-ci occupe un nombre entier de lignes de cache. Si ce n'est pas le cas, il y a un risque d'accès non-alignés, c'est à dire qu'une structure se retrouve à cheval sur deux lignes de cache, avec les défauts que cela implique. ===Le tag d'une ligne de cache=== Plus haut, nous avions dit que le cache mémorise, pour chaque ligne de cache, l'adresse RAM associée. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Mais du fait de l'organisation du cache en lignes de cache de grande taille, qui sont de plus alignées en mémoire, il faut nuancer cette affirmation. Le cache ne mémorise pas la totalité de l'adresse, ce qui serait inutile. L'alignement des lignes de cache en RAM fait que les bits de poids faible de l'adresse ne sont pas à prendre en compte pour l'association adresse-ligne de cache. Dans ces conditions, on mémorise seulement la partie utile de l'adresse mémoire correspondante, qui forme ce qu'on appelle le '''tag'''. Le reste de l'adresse indique quelle est la position de la donnée dans la ligne de cache. Par exemple, prenons le cas où le processeur gère des nombres entiers de 64 bits (8 octets) et des lignes de cache de 128 octets : chaque ligne de cache contient donc 16 entiers. Si le processeur veut lire ou écrire un entier bien précis, il doit préciser sa place dans la ligne de cache. Et ce sont les bits de l'adresse mémoire non-inclus dans le cache qui permettent de faire ça. En clair, une adresse mémoire à lire/écrire est interprété par le cache comme la concaténation d'un tag et de la position de la donnée dans la ligne de cache correspondante. [[File:Adressage d'un cache totalement associatif.png|centre|vignette|upright=2|Adressage d'un cache totalement associatif]] Le cache est donc une grande table de correspondance entre tags et lignes de cache. Lors d'un accès mémoire, le cache extrait le tag de l'adresse à lire ou écrire, et le compare avec les tags de chaque ligne de cache. Si une ligne contient ce tag, alors c'est que cette ligne correspond à l'adresse, et c'est un défaut de cache sinon. Lors d'un succès de cache, la ligne de cache est lue depuis le cache et envoyée à un multiplexeur qui sélectionne la donnée à lire dans la ligne de cache. Le fonctionnement est similaire pour une écriture : la donnée à écrire passe dans un démultiplexeur, qui envoie la donnée au bon endroit dans la ligne de cache sélectionnée. [[File:Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.png|centre|vignette|upright=2|Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.]] ===Le contenu d'une ligne de cache=== Dans ce qui va suivre, nous allons considérer que chaque ligne de cache mémorise son tag, les données de la ligne de cache proprement dit, et quelques bits de contrôle annexes qui varient suivant le cache considéré. [[File:Tag d'une ligne de cache.png|centre|vignette|upright=2|Tag d'une ligne de cache.]] Les caches modernes incluent de nombreux bits de contrôle, mais deux d'entre eux sont communs à presque tous les caches modernes : le bit ''Dirty'' et le bit ''Valid''. Le '''bit ''Valid''''' indique si la ligne de cache contient des données valides ou non. Si le bit ''Valid'' est à 0, la ligne de cache est en état valide, à savoir qu'elle contient des données et n'est pas vide. Par contre, si ce bit est à 1, la ligne de cache est invalide et son contenu ne peut pas être lu ou écrit. L'utilité de ce bit est qu'il permet d'effacer une ligne de cache très rapidement : il suffit de mettre ce bit à 0. Il existe des situations où le cache doit être effacé, on dit alors qu'il est invalidé. Une section de ce chapitre sera dédié à l'invalidation du cache. Le '''bit ''Dirty''''' indique qu'une ligne de cache a été modifiée. Par modifiée, on veut dire que le processeur a écrit dedans, qu'il a modifié la ligne de cache. Mais attention : si la donnée a été modifiée dans le cache, la modification n'est pas forcément propagée en mémoire RAM. Le bit ''dirty'' indique si c'est le cas, si l'écriture a été propagée en mémoire RAM. Il précise que la ligne de cache contient des données modifiées, alors que la RAM a des données initiales non-modifiées. Une ligne de cache avec un bit ''dirty'' à 1 est dite ''dirty'', par métonymie. Nous verrons cela en détail dans la section sur les caches ''write-back'' et ''write-through''. Les caches modernes ajoutent des '''bits de détection/correction d'erreur''' dans les bits de contrôle. Pour rappel, les codes de détection/correction d'erreur permettent de se prémunir contre des erreurs matérielles, qui corrompent les données stockées dans une mémoire, ici une mémoire cache. Ils ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Nous reviendrons dessus dans une section ultérieur de ce chapitre. Sur certains caches assez anciens, on pouvait transférer les lignes de caches morceaux par morceaux. Ces caches avaient des lignes de cache divisées en sous-secteurs, ces sous-secteurs étant des morceaux de ligne de cache qu'on pouvait charger indépendamment les uns des autres (mais qui sont consécutifs en RAM). Chaque secteur avait ses propres bits de contrôle, mais le tag était commun à tous les secteurs. [[File:Cache à secteurs.png|centre|vignette|upright=2.5|Cache à secteurs.]] : Dans ce qui va suivre, le terme "ligne de cache" désignera soit un bloc de données copiées depuis la RAM d'une taille de 64/128/256/... octets, soit la concaténation de ces données avec le tag et des bits de contrôle. Les deux définitions ne sont pas équivalentes, mais l'usage a entériné cet abus de langage. Et il faut avouer que cela rend les explications du chapitre plus simples. ==Les instructions de contrôle du cache== Plus haut, nous avions dit que le cache est totalement transparent du point de vue du programmeur. Le cache contient des copies de données en RAM, le programmeur n'a rien à faire pour utiliser le cache correctement. Mais la réalité est que pour des raisons diverses, des processeurs incorporent des '''instructions de contrôle du cache'''. Il s'agit d’instructions qui agissent sur le contenu du cache. Elles existent pour des raisons diverses qu'on détaillera plus bas, mais il s'agit globalement d'une question de performances ou de nécessité pour le système d'exploitation. ===Les instructions de préchargement=== La première instruction de contrôle du cache est une '''instruction de préchargement''', qui demande à charger un bloc de données dans le cache. Elle prend en opérande une adresse mémoire, et le contenu de cette adresse est chargé dans une ligne de cache. Bien sûr, des contraintes d'alignement sont à prendre en compte : on charge un bloc de la même taille qu'une ligne de cache, aligné en mémoire sur la taille du bloc, qui contient l'adresse. L'instruction de préchargement n'est utile que si l'instruction est exécutée bien avant que la donnée ne soit utilisée/lue/écrite. Cela permet de charger une donnée dans le cache à l'avance, d'où le nom de préchargement donné à cette technique. Mais les processeurs modernes gérent des techniques de préchargement automatique, qui ne requièrent pas d'instructions de préchargement. Le préchargement automatique et les instructions de préchargement sont deux solutions complémentaires, mais qui peuvent se marcher sur les pieds. Nous en reparlerons dans le prochain chapitre, qui sera dédié au préchargement automatique. Il faut noter que les instructions de préchargement peuvent être ignorées par le processeur. Sous certaines conditions, le processeur peut décider que l'instruction de préchargement ne sera pas exécutée. Par exemple, il ne va pas précharger une donnée déjà présente dans le cache. Ou encore, si le bus mémoire est occupé, il ne va pas exécuter le préchargement, par manque de ressources matérielles. ===Les instructions d'invalidation et de ''flush''=== Les instructions ''flush'' regroupent deux types d'instructions qui sont souvent utilisées en même temps. Il s'agit des instructions d'invalidation et de nettoyage (''clean''). Les deux termes proviennent de la terminologie ARM, il n'y a pas de terminologie standardisé pour les noms de ces instructions. Dans les grandes lignes, elles permettent de vider le cache, à savoir de rapatrier son contenu en RAM et de réinitialiser le cache à zéro. Elles sont utilisées par le système d'exploitation lors des commutations de contexte, à savoir quand on passe d'un programme à un autre. Elles sont aussi utilisées lors des appels systèmes et routines d'interruption/exception. L'idée est de vider le cache avant d'exécuter un nouveau programme ou une nouvelle routine. Le nouveau programme aura accès à un cache tout propre, les données de l'ancien programme auront été retirée du cache. Les '''instructions ''clean''''' recopient le contenu de la ligne de cache en RAM. Elles forcent la recopie immédiatement de la ligne de cache en mémoire RAM. Pour faire leur travail, elle vérifient si la ligne de cache a été modifiée, avant de la recopier en RAM. Et pour cela, ils vérifient le bit de contrôle ''dirty'', qui est mis à 1 après une première écriture. Si ce bit est à 0, alors pas besoin de recopier la ligne de cache : elle n'a pas été modifiée, la RAM a déjà la bonne copie. Mais s'il est à 1, le cache et la RAM n'ont pas le même contenu, la recopie s'exécute. Les '''instructions d'invalidation''' permettent d'invalider une ligne de cache, à savoir d'effacer son contenu. Nous verrons à quoi servent ces instructions dans la section sur les changement de processus. Invalider une ligne de cache est une opération optimisée : le cache n'est en réalité pas réellement effacé. À la place, le bit ''Valid'' de chaque ligne de cache est juste mis à 0. Il faut noter que l'invalidation efface les lignes de cache sans se préoccuper de leur contenu. Elle se moque qu'une ligne de cache contienne une donnée modifiée, ''dirty'' ou quoique ce soit : la ligne de cache est effacée, point. Il est possible d'invalider une ligne de cache en fournissant une adresse mémoire, mais il est aussi possible d'invalider le cache tout entier. Le choix entre les deux dépend du mode d'adressage de l'instruction d'invalidation. Parfois, il existe une instruction séparée pour invalider tout le cache, et une autre pour invalider une ligne de cache bien précise. Des instructions séparées sont parfois disponibles pour invalider les caches de données et d'instructions, parfois aussi la TLB (un cache qu'on verra dans quelques chapitres). Il est possible de n'invalider que le cache L1, voire le cache L2. Il faut noter que l'invalidation efface tout le cache, mais ne se préoccupe pas de vérifier si les données ont été modifiées dans le cache. Pour certains caches, comme le cache d'instruction, ce n'est pas un problème, vu qu'il est en "lecture seule". Mais pour les caches de données, les données modifiées sont perdues en cas d'invalidation. Heureusement, il existe des instructions d'invalidation qui fusionnent une instruction ''clean'' et une instruction d'invalidation. Il s'agit d''''instructions d'invalidation spéciales'''. ===Les instructions d'optimisation : instructions non-temporelles et écritures optimisées=== Les '''instructions mémoire non-temporelles''' contournent complètement le cache. Par exemple, une lecture peut lire une donnée, mais celle-ci ne sera pas chargée dans le cache, elle passe directement de la RAM vers les registres. Une section entière de ce chapitre sera dédiée au contournement du cache, à savoir aux situations où les accès mémoire doivent passer directement du processeur à la RAM sans passer par le cache. D'autres instructions assez rares incorporent des indications pour le cache. Par exemple, l'instruction ''load last'' des processeurs POWER PC implique que la donnée ne sera utilisée qu'une seule fois. Elle est donc chargée dans le cache, mais la ligne de cache est configurée de manière à être remplacée très rapidement, typiquement avec une valeur de LRU/LFU adéquate. La donnée est bien chargée dans le cache, au cas où elle doive être relue suite à une mauvaise prédiction de branchement ou autre, chose qu'une lecture non-temporelle (qui contourne le cache) ne fait pas. Des indications de ce type sont appelées des '''''cache hint'''''. L''''instruction ''flush''''' permet de préciser qu'une ligne de cache contient une donnée inutile, qui ne sera pas réutilisée par le programme. Pas besoin de la conserver dans le cache, elle peut laisser sa place à des données plus utiles. Or, sans indication, les algorithmes de remplacement d'une ligne de cache risquent de conserver cette donnée trop longtemps, ce qui entraine une certaine pollution du cache par des données inutiles. Une autre instruction est elle beaucoup plus importante : celle de '''pré-allocation sur écriture'''. Elle sert dans le cas où une ligne de cache est complétement écrite. Par exemple, imaginons qu'on veuille écrire dans une portion de mémoire. Si celle-ci n'est pas dans le cache, le processeur va charger une ligne de cache complète depuis la RAM, écrire dans la ligne de cache, puis recopier la ligne de cache modifiée en mémoire RAM. Une écriture en RAM demande donc de faire une lecture et une écriture. Mais les instructions de pré-allocation sur écriture permettent de prévenir qu'une ligne de cache sera intégralement écrite, et qu'il n'y a donc pas besoin de lire celle-ci depuis la RAM. Notons que l'instruction d'écriture qui suit n'est pas une écriture non-temporelle, vu que les données sont écrites dans la ligne de cache, qui est ensuite envoyée en mémoire RAM dès que nécessaire. De plus, les données écrites peuvent ensuite être relue depuis le cache si nécessaire. Enfin, certains processeurs MIPS incorporent une instruction pour modifier le tag d'une ligne de cache. Elles servent à optimiser les copies mémoire, à savoir quand on copie un bloc de données d'un endroit à un autre. L'idée est de charger le bloc de données dans le cache avec une instruction LOAD/PREFETCH, de modifier le tag pour qu'il pointe vers l'adresse à écrire, et de laisser faire le cache pour que l'écriture se fasse en RAM. Mais les contraintes pour utiliser cette instruction sont assez drastiques : les données doivent être alignées sur la taille d'une ligne de cache, le bloc de départ et d'arrivée (l'original versus la copie) ne doivent pas se recouvrir, etc. ==L'associativité des caches et leur adressage implicite== Lorsqu'on souhaite accéder au cache, il faut trouver quelle est la ligne de cache dont le tag correspond à l'adresse demandée. On peut classifier les caches selon leur stratégie de recherche de la ligne correspondante en trois types de caches : totalement associatifs, directement adressés (''direct mapped'') et associatifs par voie. ===Les caches totalement associatifs=== Avec les caches totalement associatifs, toute donnée chargée depuis la mémoire peut être placée dans n'importe quelle ligne de cache, sans aucune restriction. Ces caches ont un taux de succès très élevé, quand on les compare aux autres caches. [[File:Cache totalement associatif.png|centre|vignette|upright=2|Cache totalement associatif.]] Concevoir un cache totalement associatif peut se faire de deux grandes manières différentes. La première consiste tout simplement à combiner une mémoire associative avec une mémoire RAM, en ajoutant éventuellement quelques circuits annexes. La mémoire associative mémorise les tags, alors que la mémoire RAM mémorise les données de la ligne de cache, éventuellement avec quelques bits de contrôle. La ligne de cache est stockée à une adresse A dans la mémoire RAM et son tag est stocké à la même adresse, mais dans la mémoire CAM. Ce faisant, quand on envoie le tag à la mémoire CAM, elle renvoie l'adresse de la ligne de cache dans la mémoire RAM. Cette adresse est alors envoyée directement sur le bus d'adresse de la RAM, et la lecture est effectuée automatiquement. Il faut ajouter quelques circuits annexes pour garantir que les écritures se passent correctement dans les deux mémoires, mais rien de bien terrible. [[File:Cache fabriqué avec une mémoire associative et une RAM.png|centre|vignette|upright=3|Cache fabriqué avec une mémoire associative et une RAM]] Il est cependant possible d'optimiser un tel cache, en fusionnant la mémoire CAM et la mémoire RAM, afin d'éliminer des circuits redondants. Pour comprendre pourquoi, rappelons que les mémoires CAM sont composées d'un plan mémoire, d'un paquet de comparateurs et d'un encodeur. Quant à la mémoire RAM, elle est composée d'un décodeur connecté au plan mémoire. En mettant une CAM suivie d'une RAM, on a un encodeur dont l'entrée est envoyée à un décodeur. [[File:Cache totalement associatif naif.png|centre|vignette|upright=3|Cache totalement associatif naif]] Or, le décodeur réalise l'opération inverse de l'encodeur, ce qui fait que mettre les deux composants à la suite ne sert à rien. On peut donc retirer l'encodeur et le décodeur, et envoyer directement les résultats des comparateurs sur les entrées de commande du plan mémoire de la RAM. [[File:Cache totalement associatif optimisé.png|centre|vignette|upright=2|Cache totalement associatif optimisé]] Avec cette méthode, les circuits du cache ressemblent à ce qui illustré ci-dessous. Le tag est envoyé à chaque ligne de cache. Le tag envoyé est alors comparé avec le Tag contenu dans chaque ligne de cache, comme c'est le cas sur les mémoires associatives. Si une ligne de cache matche avec le tag envoyé en entrée, la ligne pour laquelle il y a eu une égalité est alors connectée sur les lignes de bit (''bitlines''). Cela est réalisé par un circuit commandé par le comparateur de la ligne de cache. Il ne reste plus qu'à sélectionner la portion de la ligne de cache qui nous intéresse, grâce à un paquet de multiplexeurs. Cela permet d'effectuer une lecture ou écriture, mais il faut aussi préciser si il y a eu un défaut de cache ou un succès. Un succès de cache a lieu quand au moins des comparaisons est positive, alors que c'est un défaut de cache sinon. En clair, détecter un succès de cache demande juste de connecter une porte OU à plusieurs entrées à tous les comparateurs. [[File:Organisation générale d'un cache totalement associatif.png|centre|vignette|upright=2|Organisation générale d'un cache totalement associatif.]] ===Les caches directement adressés=== Les caches directement adressés peuvent être vus comme un cache totalement associatif auquel on aurait ajouté des restrictions assez drastiques. Plus haut, on a vu qu'un cache totalement adressé est équivalent à la combinaison d'une CAM avec une RAM. La mémoire CAM prend en entrée un Tag et traduit celui-ci en une adresse qui commande la mémoire RAM interne au cache. Dans ce qui suit, l'adresse interne au cache sera appelé l''''indice''' pour éviter toute confusion. [[File:Cache hash table - 2.png|centre|vignette|upright=2|Fonctionnement interne du cache, expliquée sous forme abstraite, en utilisant la notion d'indice interne au cache.]] Les caches directement adressés cherchent à remplacer la mémoire CAM par un circuit combinatoire. Ce circuit traduit le Tag en indice, mais est beaucoup plus simple qu'une mémoire CAM. Mais qui dit circuit plus simple dit circuit plus limité. Un circuit combinatoire n'est pas aussi versatile que ce qui est permis avec une mémoire CAM. En conséquence, une restriction majeure apparait : toute adresse mémoire est associée dans une ligne de cache prédéfinie, toujours la même. L'association entre ligne de cache et adresse mémoire est faite par le circuit combinatoire, et ne peut pas changer. Les concepteurs de caches s'arrangent pour que des adresses consécutives en mémoire RAM occupent des lignes de cache consécutives, par souci de simplicité. Tout se passe comme suit la mémoire RAM était découpés en blocs de la même taille que le cache. La première adresse du bloc est associée à la première ligne de cache (celle d'indice 0), la seconde adresse est associée à la seconde adresse du_ bloc, et ainsi de suite. Le tout est illustré ci-dessous. [[File:Cache adressé directement.png|centre|vignette|upright=2|Cache adressé directement.]] Avec cette contrainte, le circuit de traduction de l'adresse en adresse mémoire pour la RAM interne au cache est drastiquement simplifié, et disparait même. Une partie de l'adresse mémoire sert à indiquer la position de la donnée dans le cache, le reste de l'adresse sert encode le tag et la position de la donnée dans le ligne de cache. [[File:Cache line.png|centre|vignette|upright=2|Adresse d'une ligne de cache sur un cache adressé directement.]] Un cache directement adressé est conçu avec une RAM, un comparateur, et un paquet de multiplexeurs. En général, la mémoire RAM stocke les lignes de caches complète. Il arrive que l'on utilise deux mémoires RAM : une pour les tags et une pour les données, mais cette technique augmente le nombre de circuits et de portes logiques nécessaires, ce qui réduit la capacité du cache. L'index à lire/écrire est envoyé sur l'entrée d'adresse de la RAM, la RAM réagit en mettant la ligne de cache sur sa sortie de donnée. Sur cette sortie, un comparateur compare le tag de la ligne de cache lue avec le tag de l'adresse à lire ou écrire. On saura alors si on doit faire face à un défaut de cache. Ensuite, un multiplexeur récupère la donnée à lire/écrire. [[File:Direct mapped cache - french.png|centre|vignette|upright=2|Cache directement adressé.]] L'accès à un cache directement adressé a l'avantage d'être très rapide vu qu'il suffit de vérifier une seule ligne de cache : celle prédéfinie. Mais ces caches ne sont cependant pas sans défauts. Vu que le cache est plus petit que la mémoire, certaines adresses mémoires se partagent la même ligne de cache. Si le processeur a besoin d’accéder fréquemment à ces adresses, chaque accès à une adresse supprimera l'autre du cache : tout accès à l'ancienne adresse se soldera par un défaut de cache. Ce genre de défauts de cache causés par le fait que deux adresses mémoires ne peuvent utiliser la même ligne de cache s'appelle un '''défaut par conflit''' (''conflict miss''). Les défauts par conflit n'existent pas sur les caches totalement associatifs. En conséquence, le taux de succès des caches directement adressés est assez faible comparé aux autres caches. [[File:Cache Block Basic Conflict.svg|centre|vignette|upright=1.5|Exemple de ''Conflict Miss''.]] ===Les caches associatifs par voie=== Les caches associatifs par voie sont un compromis entre les caches directement adressés et les caches totalement associatifs. Pour simplifier, ces caches sont composés de plusieurs caches directement adressés accessibles en parallèle, chaque cache/RAM étant appelé une '''voie'''. Avec ces caches, toute adresse mémoire en RAM est associée à une ligne de cache dans chaque voie. [[File:Cache associatif par voie.png|centre|vignette|upright=2|Cache associatif par voie.]] Le schéma ci-dessous compare un cache directement adressé et un cache associatif à deux voies. On voit que chaque adresse est associée à une ligne de cache bien précise avec un cache directement dressé, et à deux lignes de cache avec un cache associatif à deux voies. L'adresse sera associée à 4 lignes de cache sur un cache associatif à 4 voies, à 8 lignes pour un cache à 8 voies, etc. L'ensemble des lignes de cache associées à une adresse est appelé un '''ensemble'''. [[File:Cache Fill.svg|centre|vignette|upright=2|Comparaison entre un cache directement adressé et un cache associatif à deux voies.]] Sur ces caches, toute adresse est découpée en trois parties : un tag, un index, et un décalage, comme sur les caches directement adressés. Comme vous pouvez le voir, l'organisation est identique à celle d'un cache totalement associatif, à part que chaque ensemble tag-ligne de cache est remplacé par une mémoire RAM qui en contient plusieurs. [[File:Implémentation d'un cache associatif par voie.png|centre|vignette|upright=2|Implémentation d'un cache associatif par voie.]] Le risque de conflits d'accès au cache est donc réduit sur un cache associatif à plusieurs voies, et il est d'autant plus réduit que le cache a de voies. Par contre, leur conception interne fait qu'ils ont un temps d'accès légèrement élevé que les caches directement adressés. Les caches associatifs par voie ont donc un taux de succès et un temps d'accès intermédiaire, situé entre les caches directement adressés et totalement associatifs. Ils sont une sorte de compromis entre réduction des défaut par conflits d'accès au cache et temps d'accès, et complexité des circuits. ==Les optimisations des caches associatifs par voie== Les caches partiellement associatifs regroupent les caches associatifs par voie et directement adressés, ainsi que leurs variantes. En clair : tous les caches qui ne sont pas totalement associatifs. Ils peuvent être optimisés de nombreuses manières, que ce soit pour gagner en performance ou pour économiser de l’énergie. Dans cette section, nous allons voir quelles sont ces optimisations. ===Les caches pseudo-associatifs=== Les caches adressés par voie contiennent une mémoire SRAM par voie. En théorie, les voies sont accédées en parallèles, en même temps, afin de voir si l'on a un succès de cache ou un défaut. Les '''caches pseudo-associatifs''' sont identiques aux caches associatifs par voie, si ce n'est qu'ils vérifient chaque voie une par une. Ils ont été utilisés sur des processeurs commerciaux, un exemple étant l'IBM 370. Là encore, on perd en performance pour gagner en consommation d'énergie. Le temps d'accès dans le meilleur des cas est plus faible pour les caches pseudo-associatifs, mais le pire des cas teste tous les caches avant de tomber sur le bon. Les performances sont donc réduites. Mais la consommation énergétique est meilleure, vu qu'on ne vérifie pas forcément toutes les voies en parallèle. On teste la première voie, éventuellement la seconde, peut-être la troisième, etc. Mais dans le cas général, on ne teste qu'une partie des voies, pas toutes, ce qui donne un gain en termes d'énergie. L'implémentation de caches de ce genre demande que l'on parcoure les voies une par une, en commençant de la première jusqu'à la dernière. Pour cela, un simple compteur suffit. Suivant la valeur du compteur, la voie associée est activée puis accédée. Toute la complexité revient à ajouter un circuit qui prend la valeur du compteur, et active la voie associée, lance un accès mémoire dessus. Vu que les voies sont chacune des caches ''direct mapped'', il suffit pour cela de geler les entrées d'adresse, soit en les déconnectant, soit en utilisant du ''clock gating'' ou de l'évaluation gardée. Les détails d'implémentation, non-cités ici, varient selon le cache. ===La prédiction de voie=== Pour réduire le temps d'accès des caches pseudo-associatifs, certains chercheurs ont inventé la '''prédiction de voie''', qui consiste à faire des paris sur la prochaine voie accédée. L'idée est d'accéder à la voie qui contient la donnée voulue du premier coup, en lisant celle-ci en priorité. Dans son implémentation la plus simple, le cache reste un cache pseudo-associatif. Lors d'un accès au cache, les voies sont toutes parcoures une par une. Par contre, les voies ne sont donc pas parcourues de la première vers la dernière, mais dans un ordre différent. Cette technique permet de mettre en veille les voies sur lesquels le processeur n'a pas parié, ce qui permet de diminuer la consommation énergétique du processeur. C'est plus efficace que d'aller lire plusieurs données dans des voies différentes et de n'en garder qu'une. L'implémentation est assez simple : il suffit d'ajouter un circuit de prédiction de voie,relié au compteur de voie. Une amélioration de la technique fait fonctionner le cache comme un intermédiaire entre cache pseudo-associatif et associatif par voies. L'idée est de chercher la voie prédite en premier, puis de chercher dans toutes les voies en parallèle en cas de défaut de cache. Au lieu d'attendre que les comparaisons de tags donnent leur résultat, le processeur sélectionne automatiquement une voie et configure les multiplexeurs à l'avance. Si le processeur ne se trompe pas, le processeur accède à la donnée plus tôt que prévu. S'il se trompe, le processeur annule la lecture effectuée en avance et recommence en faisant un accès en parallèle aux autres voies. Le compromis entre performance et consommation d'énergie est alors différent. On économise de l'énergie par rapport à un cache associatif par voie, au prix d'une petite perte de performance (doublement des temps d'accès). Mais par rapport à un cache pseudo-associatif, l'économie d'énergie est bien moindre, au prix d'un gain en performance assez manifeste. Prédire quelle voie sera la bonne est assez simple. En vertu du principe de localité, les accès futurs ont des chances de tomber dans les voies les plus fréquemment utilisées ou dans celle plus récemment utilisée. Il suffit de retenir la voie la plus récemment accédée dans un registre, qui sera utilisée comme prédiction. Pour vérifier que la prédiction est correcte, il suffit de comparer le registre et le résultat obtenu après vérification des tags. Cependant, on peut complexifier l'implémentation pour prendre en compte l'adresse à lire/écrire, l'instruction à l'origine de l'accès mémoire ou tout autre paramètre utile. Par exemple, des instructions différentes ont tendance à aller chercher leurs données dans des ensembles différents et la voie à choisir n'est pas la même. Pour cela, il suffit d'utiliser un cache pour stocker la correspondance instruction - voie. Pour plus de simplicité, la mémoire cache des prédictions est parfois remplacée par une RAM, qui est adressée : * soit par le program counter de l'instruction à l'origine de l'accès (en réalité, seulement quelques bits de poids faible de l'adresse) ; * soit par l'adresse à accéder (là encore, quelques bits de poids faible) ; * soit (pour les modes d'adressage qui utilisent un registre de base et un décalage) par un XOR entre les bits de poids faible de l'adresse de base et le décalage ; * soit par autre chose. ===La mise en veille sélective des voies=== Les caches associatifs ont tendance à utiliser beaucoup d'énergie, même quand on n'y accède pas. Aussi, certains processeurs détectent quand le cache est peu utilisé et en profitent pour mettre en veille les voies inutilisées. Vous vous demandez certainement ce qui se passe quand une donnée à lire/écrire est dans une voie désactivée. La réponse est que le cache détecte cette situation, car elle déclenche un succès de cache. Les ''tags'' ne sont en effet pas désactivés, seules les données sont mises en veille. L'implémentation est plus simple sur les caches qui séparent les tags et les données dans deux RAM différentes. Cette optimisation marche surtout sur les gros caches, qui ont des chances d'avoir une portion significative d’inutilisée (pas assez de données pour les remplir), donc généralement les caches L3/L4. Par exemple, les processeurs d'Intel de microarchitecture Ivy Bridge disposent d'un cache de 8 mébioctets à 16 voies, qu'ils peuvent faire passer à 512 kibioctets si le besoin s'en fait sentir. Quand ces processeurs détectent une faible activité, ils mettent en veille 14 voies et n'en gardent que 2 d'actives. Évidemment, les 14 voies sont vidées avant d'être mises en veille, afin qu'une aucune donnée ne soit perdue. ===Les caches ''skew-associative''=== Vous aurez remarqué que dans une voie, les lignes sont accédées en adressage direct : les défauts par conflit sont possibles sur un cache associatif par voie. Pour éviter cela, certains chercheurs ont créé des '''caches ''skew associative''''' (ou associatifs à biais). Pour faire simple, les index des lignes de cache subissent un petit traitement avant d'être utilisés. Le traitement en question est différent suivant la voie de destination, histoire que deux adresses mémoires avec des index identiques donnent des index différents après traitement. Le traitement en question est souvent une permutation des bits de l'index, qui est différente suivant la voie prise, ou un simple XOR avec un nombre qui dépend de la voie. [[File:Implémentation d'un cache skew associative.jpg|centre|vignette|upright=2|Implémentation d'un cache skew associative.]] ==Les caches splittés (''phased caches'')== Dans cette section, nous allons voir les '''caches splittés''' (''phased caches''), qui sont une variante des caches ''direct-mapped'', dans lequel le cache est accédé en deux étapes consécutives. Il ne s'agit pas des caches pipelinés, que nous verrons dans le chapitre sur les processeurs pipélinés, mais laissons cela à plus tard. Il est possible d'appliquer la même méthode sur un cache associatif par voie, mais il y a des méthodes plus simples, qui permettent là aussi d’accéder au cache en plusieurs étapes consécutives. L'idée est de scinder le cache en deux : une mémoire pour les tags, une autre pour les données de la ligne de cache. Les bits de contrôle peuvent être mis dans l'une ou l'autre SRAM, mais ils sont souvent mis dans la RAM pour les tags. En faisant cela, quelques optimisations deviennent possibles, afin de réduire la consommation énergétique en contrepartie d'une perte de performance. La technique s'implémente différemment pour les caches totalement associatifs et partiellement associatifs. Les caches totalement associatifs splittés sont ceux formés en combinant un cache associatif avec une CAM et une RAM combinée. On envoie l'adresse à lire/écrire à la mémoire associative, elle répond en envoyant une adresse à la mémoire RAM. L'accès se fait donc en deux temps, avec l'adresse dans la RAM comme intermédiaire. Il est possible de séparer physiquement les deux étapes en insérant un registre entre la CAM et la RAM, ce qui permet aussi de pipeliner l'accès. Mais c'est rarement fait en pratique, car le cout en circuit d'une mémoire CAM est trop important. L'équivalent pour un cache totalement associatif optimisé, sans CAM et RAM séparée, est trop gourmande en interconnexions pour être implémentée. Les caches totalement associatifs splittés sont donc très rares, l'auteur ne connait aucun exemple de processeur avec un tel cache. Il existe une technique équivalente pour les caches ''direct-mapped'', mais elle demande une certaine modification du cache. Dans les caches ''direct-mapped'' non-splittés, on trouve une mémoire SRAM dont chaque mot mémoire contient une ligne de cache entière, tag inclus. Dans leurs versions splittés, la SRAM est séparée en deux : une pour les tags, une autre pour les données. Précisons qu'il s'agit bien de deux mémoires SRAM adressables. L'adresse à laquelle accéder est envoyée à la SRAM des tags, puis ensuite à la SRAM des données si besoin. L'idée est d’accéder aux tags pour déterminer s'il y a un succès de cache ou un défaut, et ensuite d'accéder aux données. On n’accède pas aux données en parallèle des tags. Faire cela est évidemment plus lent. En cas de défaut de cache, le temps d'accès est similaire : le tag ne correspond pas, on n'accède pas à la SRAM pour les données. Par contre, vu qu'on n'a pas activé la SRAM pour les données, on économise un peu d'énergie, ce qui réduit la consommation d'énergie. En cas de succès de cache, on accède à la SRAM pour les tags, puis à celle pour les données. Pas d'économie d'énergie à l'horizon, sans compter que le temps d'accès augmente : on accède au cache en deux étapes au lieu de faire les deux accès en parallèle. [[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]] Précisons cependant que ce design peut avoir deux avantages en termes de performance. Premièrement, le temps d'accès au cache est légèrement amélioré en cas de défaut de cache. En effet, la SRAM des tags est assez petite, idem pour celle des données. Leur temps d'accès est donc plus faible que pour une grosse SRAM contenant données et tags. Le gain en temps d'accès est donc un avantage, qui ne se manifeste surtout en cas de défaut de cache. Un autre avantage est que l'accès au cache se pipeline plus facilement, ce qui fait qu'on peut effectuer plusieurs accès simultanés au cache. Mais nous verrons cela dans quelques chapitres. ===L'exemple des processeurs 386 et du contrôleur de cache 82385=== Il est important de noter que la séparation entre tags et RAM peut être telle que les deux ne sont pas sur la même puce de silicium ! Voire que les deux sont séparés du processeur ! C'était le cas quand les mémoires cache ont été introduites sur les processeurs grand publics, notamment sur les premiers processeurs Intel. La miniaturisation n'avait pas avancé au point où placer un cache dans le processeur était possible. Sur le processeur 386 d'Intel, le cache était un cache splitté, séparé du processeur. Concrètement, le processeur i386 était couplé à un contrôleur de cache Intel 82385 contenant les ''tags'', et une mémoire SRAM pour les lignes de cache. Le 82385 était un composant passif, qui n'était pas un intermédiaire entre processeur et mémoire RAM. Il surveillait ce qui se passait sur le bus de données et répondait à la place de la RAM pour certaines lectures. La conséquence est que c'était un cache ''write through'' : le processeur envoyait les écritures sur le bus mémoire, le cache les voyait passer et agissait en conséquence en cas de succès de cache. Le 82385 avait une gestion de la cohérence des caches, qui fonctionnait par invalidation. Dès qu'il détectait une prise de contrôle du bus par autre chose que le processeur, il invalidait son contenu. Il gérait aussi les accès mémoire non-cacheable, à savoir des accès mémoire qui ne doivent pas être pris en compte par le cache. Il permettait de programmer des intervalles d'adresse auxquels ne pas répondre. Le cache était mis en pause lors des interruptions, mais aussi lors des accès à des entrée-sorties (le processeur utilisait un espace d'adressage séparé pour les entrées-sorties, le bit IO inhibait l'action du cache). ===L'exemple des processeurs Intel de microarchitecture ''Broadwell''=== Un autre exemple est celui du cache L4 des processeurs Broadwell et de quelques processeurs séparés. Ces processeurs ont une organisation en ''chiplet'' où le processeur incorpore plusieurs puces séparées : une puce pour le processeur proprement dit, une puce nommée ''Crystal Well'' pour le cache L4, et une puce IO pour la communication avec la RAM et la carte mère. Le processeur incorporait un cache L4 de 128 mébioctets, composé de mémoire eDRAM, qui était dispersé entre ''Crystal Well'' et les autres puces. Les données du cache L4 étaient dans ''Crystal Well'', alors que les Tags étaient soit dans le processeur lui-même, soit dans la puce IO ! La puce ''Crystal Well'' était une mémoire DRAM adressable tout ce qu'il y a de plus basique, avec cependant quelques optimisations notables. Par exemple, elle avait deux bus séparés pour l'écriture et la lecture. De plus, elle avait une organisation interne avec 128 banques, contre moins d'une dizaine pour la DDR de l'époque et environ 32 banques pour la DDR5 moderne. Elle contenait aussi quelques circuits pour gérer son rôle de mémoire cache, mais rien en ce qui concerne la gestion des tags eux-mêmes. Sur les processeurs de microarchitecture ''Broadwell'', les tags étaient placés dans le CPU et précisément dans le cache L3. À chaque accès mémoire au cache L3, les tags du cache L4 étaient consultés en parallèle. De fait, l'accès au cache L4 était assez rapide, malgré le fait que les données étaient dans une puce à part. Ajoutons à cela que le processeur et ''Crystal Well'' n'avaient pas la même finesse de gravure ni la même technologie de fabrication. Les tags étaient implémentés avec de la SRAM contre la DRAM pour les données, ce qui fait que la consultation des tags était plus rapide que l'accès aux données. Par la suite, dans certains CPU de microarchitecture ''skylake'', les tags ont été déplacés en-dehors du processeur pour finir dans le contrôleur mémoire. En faisant cela, le cache L4 pouvait être utilisé par autre chose que le processeur, et notamment par la carte graphique intégrée au CPU. Avec ''broadwell'', le fait que les tags étaient consultés en cas d'accès au L3 empêchait au GPU intégré de consulter le cache L4. Mais en déplaçant les tags dans le contrôleur mémoire, ce n'est plus le cas vu que la carte graphique a aussi accès au bus mémoire. Par contre, le temps d'accès augmente comparé à la solution précédente. On n'accède pas aux tags du L4 en parallèle du L3 : à la place, il faut consulter les tags du L3, détecter un défaut de cache L3, et ensuite accèder aux tags. ===Les caches RAM-configurables=== Un autre avantage des caches splittés est qu'on peut les modifier pour servir à la fois de mémoire cache, mais aussi de ''local store'', de mémoire RAM de petite taille. Le fonctionnement est assez simple à comprendre. Lors d'un accès au cache, on accède aux tags, puis à la RAM interne au cache. Lors d'un accès au ''local store'', on contourne l'accès au tags et on accède à la RAM interne au cache directement. Il s'agit de la technique du '''cache RAM-configurable''. L'usage de cache RAM-configurable est fréquent sur les cartes graphiques récentes, qui incorporent un ou plusieurs processeurs multicoeurs, dont le cache L1 de données est un cache RAM-configurable. [[File:Hydride cache - local store.png|centre|vignette|upright=2.0|Hydride cache - local store]] ===La compression de cache=== Une autre optimisation permise par les ''phased caches'' est l'implémentation de techniques de '''compression de cache''', qui visent à compresser des lignes de cache. L'intérêt est qu'on peut stocker plus de données dans le cache, à capacité égale. L'inconvénient est qu'on doit compresser/décompresser les lignes de cache, ce qui demande un circuit en plus et allonge les temps d'accès. En effet, le temps mis pour compresser/décompresser une ligne de cache s'ajoute au temps d'accès. Aussi, la compression de cache sert surtout pour les caches de bas niveau dans la hiérarchie mémoire, les gros caches aux temps d'accès assez longs. Une première technique, assez simple à implémenter et peu couteuse en circuit, est celle de la '''compression des lignes de cache nulles'''. Elle compresse uniquement les lignes de cache qui ne contiennent que des zéros. L'idée est qu'on ajoute, dans la mémoire des tags, un bit de contrôle pour chaque ligne de cache appelé le bit ''null''. Il indique si la ligne de cache ne contient que des zéros. Quand on lit une ligne de cache, la mémoire des tags est accédée et on vérifie le bit ''null'' : s'il vaut 1, on n'accède pas à la mémoire cache de données et un multiplexeur envoie un zéro sur le port de lecture. Le bit ''null'' est fixé lors de l'écriture d'une ligne de cache : elle passe dans un comparateur avec zéro relié à la mémoire des tags. La comparaison avec zéro peut se faire en parallèle de l'écriture ou avant (dans ce cas, on n'écrit pas la ligne de cache dans le cache). Les autres techniques de compression de cache permettent de compresser autre chose que des lignes de cache nulles. L'idée est qu'une ligne de cache physique peut par moment mémoriser plusieurs lignes de caches compressées. Par exemple, prenons un cache dont les lignes de cache font 64 octets. Il est possible de compresser deux lignes de cache pour qu'elles fassent chacune 32 octets, et les stocker dans une seule ligne de cache. Les deux lignes de cache auront des tags différents, mais pointeront sur la même ligne de cache physique. Et cela demande d'utiliser un ''phased cache'' dont la mémoire pour les tags est plus grande que la mémoire pour les données. Il n'y a donc plus une bijection entre tags et ligne de cache, mais une relation surjective. Chose qui n'est possible qu'avec un ''phased cache''. De plus, des bits de contrôles associés à chaque ''tag'' indiquent où se trouvent les lignes de cache compressées dans la ligne de cache : est-ce que c'est les 32 octets de poids fort ou de poids faible ? [[File:Compression de cache.png|centre|vignette|upright=2|Compression de cache]] Il ne semble pas que les techniques de compression de cache soient implémentées sur les processeurs modernes. Aucun n'utilise de compression de cache, à ma connaissance. Il faut dire que les techniques connues sont de mauvais compromis : le temps d'accès du cache augmente beaucoup, le cout en circuit pourrait être utilisé pour un cache non-compressé mais plus grand. Et notons que la compression de cache ne marche que si les données peuvent se compresser. Si ce n'est pas le cas, une partie de la mémoire des tags est inutilisée. Une revue de la littérature académique sur la compression de cache est disponible via ce lien, pour les curieux : * [https://inria.hal.science/hal-03285041 Understanding Cache Compression, par Carvalho et Seznec]. ==L'adressage physique ou logique des caches== Le cache utilise les adresses à lire/écrire pour déterminer s'il a une copie de la donnée en son sein. Mais l’interaction entre caches et mémoire virtuelle donne lieu à un petit problème : l'adresse utilisée est-elle une adresse virtuelle/logique ou physique ? La réponse varie suivant le processeur : certains caches utilisent l'adresse virtuelle, tandis que d'autres prennent l'adresse physique. On parle de cache '''virtuellement tagué''' dans le premier cas et de cache '''physiquement tagué''' dans le second. {| |[[File:Cache tagué virtuellement.png|vignette|Cache tagué virtuellement.]] |[[File:Cache tagué physiquement.png|vignette|Cache tagué physiquement.]] |} ===L'accès à un cache physiquement/virtuellement tagué=== La manière d'accéder à un cache dépend de s'il est virtuellement ou physiquement tagué. Il faut utiliser l'adresse virtuelle pour les premiers, physique pour les seconds. Avec un cache virtuellement tagué, l'adresse logique peut être envoyée directement au cache. La MMU ne traduit les adresses que s'il faut accéder à la mémoire RAM. Ces caches sont donc plus rapides. Avec un cache physiquement tagué, le processeur doit traduire l'adresse logique en adresse physique dans la MMU, avant d'accéder au cache. La traduction d'adresse se fait soit en accédant à une table des pages en mémoire RAM, soit en accédant à un cache spécifiquement dédié à accélérer la traduction d'adresse, la TLB (''Translation Lookaside Buffer''). Dans la quasi-totalité des cas, la traduction d'adresse passe par la TLB, ce qui fait qu'elle est raisonnablement rapide. Toujours est-il que chaque accès au cache demande d'accéder à la TLB et de faire la traduction d'adresse avant d'accéder au cache. L'accès est donc plus lent que sur les caches virtuellement tagués, où les accès sont plus directs. [[File:Virtual and Physical addressing.svg|centre|vignette|upright=2|Cache tagué virtuellement versus physiquement tagué.]] ===Les défauts des caches virtuellement tagués=== Les caches physiquement tagués sont moins rapides que les caches virtuellement adressés. Pourtant, les caches virtuellement tagués sont peu fréquents sur les processeurs modernes. Et la raison est assez intéressante : c'est une question d'adresses homonymes et synonymes. ====Les droits d'accès doivent être vérifiés lors d'un accès au cache==== Un premier problème est que la protection mémoire est compliquée avec de tels caches. Rappelons que certaines portions de mémoire sont accessibles seulement en lecture, ou sont interdites en écriture, sont inexécutables, etc. Ces droits d'accès sont gérés par la MMU, qui vérifie pour chaque accès mémoire que l'accès est autorisé. En bypassant la MMU, l'accès au cache virtuellement tagué ne permet pas de faire ces vérifications. Il est possible de charger une donnée en lecture seule dans le cache, mais d'y faire des accès en écriture pour les accès ultérieurs. Les solutions à cela sont multiples. La première consiste à consulter la MMU en parallèle de l'accès au cache. L'accès au cache est alors réalisé de manière spéculative, et est ensuite confirmé/annulé une fois que la MMU a rendu son verdict. Les performances du cache restent alors les mêmes : l'accès à la MMU se fait en parallèle de l'accès au cache, pas avant. Une autre solution est d'ajouter les droits d'accès en question dans la ligne de cache, dans les bits de contrôle situés après le Tag. Chaque accès au cache récupère ces bits de contrôle et vérifie si l'accès est autorisé. L'inconvénient est que les lignes de cache deviennent plus longues, les droits d'accès sont dupliqués entre MMU et cache. Mais si le budget en transistor suit, ce n'est rien d'insurmontable. ====Les adresses homonymes perturbent la gestion du cache==== Pour rappel, une adresse logique homonyme correspond à plusieurs adresses physiques différentes. Elles surviennent quand chaque programme a son propre espace d'adressage. Dans ce cas, une adresse logique correspondra à une adresse physique différente par programme.Une autre manière de voir les choses est qu'il y a en réalité deux adresses homonymes, qui ont la même valeur, mais appartiennent à des espaces d'adressage différentes. Et c'est cette seconde interprétation que nous allons utiliser. Les caches doivent gérer ces adresses homonymes et faire en sorte que la lecture/écriture d'une adresse homonyme se fasse à la bonne adresse physique, dans la bonne ligne de cache. Et autant un cache physiquement tagué n'a aucun problème avec ça, vu qu'il ne gère que des adresses physiques, autant des problèmes surviennent avec les caches virtuellement tagués. Le problème est que les caches virtuellement tagués doivent faire la différence entre deux adresses homonymes de même valeur. Pour corriger ces problèmes, il existe deux grandes méthodes. La première méthode est simple : '''vider les caches''' en changeant de programme. Leur contenu est rapatrié en mémoire RAM, puis les caches sont remis à zéro. Le vidage du cache recopie les lignes de cache ''dirty'' (modifiées) en RAM, puis efface/invalide tout le cache. C'est à cela que servent les instructions ''clean'' et d'invalidation vues plus haut, elles ont été inventées pour cette situation précise. Lorsque le système d'exploitation déclenche une commutation de contexte, à savoir qu'il change le programme en cours d'exécution, le processeur vide tous les caches du processeur. Les interruptions font la même chose, elles vide tous les caches du processeur. Une seconde méthode numérote chaque programme en cours d'exécution, chaque processus. Le numéro attribué est spécifique à chaque processus, ce qui fait qu'il est appelé un '''identifiant de processus CPU'''. Le processeur mémorise l'identifiant du programme en cours d'exécution dans un registre dédié. L'identifiant de processus CPU est utilisé lors des accès mémoire. Chaque ligne de cache contient le numéro de l'espace d'adressage associé, dans son ''tag''. Lors de chaque accès mémoire, l'ID du registre est comparé à l'ID de la ligne de cache accédée, pour vérifier que l'accès mémoire accède à la bonne donnée. Cette méthode n'est pas très économe en termes de transistors. L'usage d'identifiant de processus CPU est clairement meilleure en termes de performance, les commutations de contexte sont plus rapides. Par contre, le budget en transistor est plus important. Un autre défaut de cette méthode est que l'identifiant de processus est généralement codé sur une dizaine de bits, alors que le système d'exploitation utilise des identifiants de processus beaucoup plus larges, de 32 à 64 bits sur les CPU 32/64 bits. L'OS doit gérer la correspondance entre identifiants de processus CPU et ceux de l'OS. Parfois, pour cette raison, les OS n'utilisent pas toujours ce système d'identifiant de processus CPU. ====Les adresses synonymes perturbent aussi la gestion du cache==== La gestion des adresses synonymes est aussi un gros problème sur les caches virtuellement tagués. Pour rappel, il s'agit du cas où des adresses logiques différentes pointent vers la même adresse physique. Typiquement, quand deux programmes se partagent un morceau de mémoire, ce morceau correspondra à des adresses synonymes dans les deux espaces d'adressage. Mais il arrive que l'on ait des adresses synonymes dans le même espace d'adressage, ce n'est pas si rare ! Autant les adresses synonymes ne posent aucun problème avec les caches physiquement tagués, ce n'est pas le cas avec les caches virtuellement adressés. Sur ces caches, deux adresses logiques synonymes vont tomber dans deux lignes de cache différentes. Corriger ce problème demande d'ajouter des circuits annexes pour détecter les adresses synonymes, qui sont vraiment complexes et ont un cout en termes de performance. Aussi, les caches virtuellement tagués sont très peu utilisés sur les processeurs modernes. ===Les caches virtuellement adressés, mais physiquement tagués=== Si les caches physiquement et virtuellement tagués ont des défauts, il existe un intermédiaire qui est un bon compromis entre ces deux extrêmes. Il s'agit des '''caches virtuellement adressés - physiquement tagués''', aussi appelés '''caches pseudo-virtuels'''. Pour comprendre comment ils fonctionnent, précisons que ces caches sont soit des caches ''direct-mapped'', soit des caches associatifs par voie (composés de plusieurs RAM ''direct-mapped'' accédées en parallèle, plusieurs voies). L'accès à ce genre de cache se fait en deux temps : on accède à un ou plusieurs RAM ''direct-mapped'' et on vérifie ensuite les ''Tags'' pour sélectionner la bonne voie. Sur les caches ''direct-mapped'', on n'a qu'une seule RAM ''direct-mapped''. Sur les caches associatifs, on a plusieurs RAM ''direct-mapped'', appelées des voies, qui sont accédées en parallèle. L'accès se fait donc en deux étapes : adresser les RAM ''direct-mapped'' avec un indice, vérifier les ''tags'' avec le reste de l'adresse. Une autre chose à rappeler est que l'adresse logique est composée de deux parties : un numéro de page logique qui indique dans quel page se situe l'adresse, un décalage/''offset'' qui indique la position de l'adresse dans la page. La traduction d'adresse transforme le numéro de page logique en numéro de page physique, mais laisse le décalage intouché. L'idée est d'utiliser le décalage pour adresser les RAM avec le décalage, tandis que le numéro de page sert de ''tag''. Le décalage est découpé en deux lors de l'accès au cache : les bits de poids fort forment l'indice (l'adresse envoyée à la voie), les bits de poids faible donnent la position de l'adresse dans la ligne de cache. L'idée est d'utiliser un numéro de page physique pour les ''tags'', mais d'adresser les voies avec le décalage logique. Les deux servent à des instants différents : vérification des ''tags'' pour l'adresse physique, accès aux voies pour l'adresse logique. Ainsi, le problème des adresses synonymes ou homonymes est résolu par l'utilisation de l'adresse physique pour les tags. Par contre, l'accès au cache est plus rapide, car on utilise l'adresse logique pour la première étape. Le processeur accède à la TLB et récupère l'adresse physique pendant que l'on adresse les voies, les deux sont faits en parallèle, ce qui fait que tout se passe comme si l'accès à la TLB était gratuit. La TLB étant assez rapide comparé au cache, l'adresse physique est disponible quand on doit faire la comparaison avec les ''tags''. [[File:Virtual - Physical - Pseudo Virtual addressing.svg|centre|vignette|upright=2|Adressage pseudo virtuel des caches.]] Il s'agit d'un excellent compromis entre performance et correction des problèmes des adresses synonymes/homonymes. Tous les caches des processeurs haute performance utilisent cette méthode, au moins pour leurs caches L1. Les caches L2 tendent à utiliser des caches physiquement adressés, pour lesquels la latence d'accès est suffisante pour qu'on accède à la TLB en amont. La raison est assez simple à expliquer, elle provient d'une contrainte assez précise sur le calcul de l'indice. La conséquence est qu'un cache ''direct-mapped'' ne peut pas dépasser la taille d'une page, soit 4 kibioctets sur les ordinateurs actuels. Sur les caches associatifs, on peut dépasser cette limite en augmentant le nombre de voies, mais la taille maximale d'une voie reste celle d'une page. Cette contrainte n'est pas trop grave sur les caches de petite taille, dont les caches L1. La plupart d'entre eux ont trouvé un compromis idéal avec moins d'une dizaine de voies par cache, chacun de 4 kibioctets, ce qui donne des caches allant de 16 à 64 kibioctets, soit entre 4 et 16 voies. Par contre, un cache de grande taille doit utiliser un grand nombre de voies, ce qui est peu pratique. Aussi, cette technique de caches pseudo-virtuels n'est pas toujours appliquée sur les caches L2, qui sont physiquement adressés. Il faut dire qu'on accède au cache L2 lors d'un défaut dans le cache L1, et l'adresse physique est disponible à ce moment-là, elle a déjà été récupérée lors de l'accès au cache L1. On peut donc l'utiliser pour adresser le cache L2 sans perte de performance. ==Le remplacement des lignes de cache== Lorsqu'un cache est rempli et qu'on charge une nouvelle donnée dedans, il faut faire de la place pour cette dernière. Dans le cas d'un cache directement adressé, il n'y a rien à faire vu que la ligne de cache à évincer est déterminée lors de la conception du cache. Mais pour les autres caches, la donnée peut aller dans n'importe quelle ligne ou voie. Or, le choix des données à rapatrier en RAM doit être le plus judicieux possible : on doit virer de préférence des données inutiles. Rapatrier une donnée qui sera surement utilisée sous peu est inutile, et il vaudrait mieux supprimer des données qui ne serviront plus ou alors dans longtemps. Il existe différents algorithmes spécialement dédiés à résoudre ce problème efficacement, directement câblés dans les unités de gestion du cache. Certains sont vraiment très complexes, aussi je vais vous présenter quelques algorithmes particulièrement simples. Mais avant de voir ces algorithmes, il faut absolument que je vous parle d'une chose très importante. Quel que soit l'algorithme en question, il choisit la ligne de cache à évincer et recopie son contenu dans la RAM. Ce qui demande d'identifier et de sélectionner une ligne de cache parmi toutes les autres. Pour cela, le circuit de remplacement attribue une adresse chaque ligne de cache ! Vous avez bien vu : chaque ligne de cache est numérotée par une adresse, interne au cache. ===Le remplacement aléatoire=== Premier algorithme : la donnée effacée du cache est choisie au hasard ! C'est contre-intuitif, mais cet algorithme donne des résultats assez honorables, en plus d'utiliser très peu de portes logiques (un générateur de nombres pseudo-aléatoire est un circuit assez simple). Généralement, les défauts de cache sont séparés par un nombre assez important et irrégulier de cycles d'horloge. Dans ces conditions, cette technique donne un bon résultat. ===FIFO : first in, first out=== Avec l'algorithme FIFO, la donnée effacée du cache est la plus ancienne, celle chargée dans le cache avant les autres. Cet algorithme est très simple à implémenter en circuit, concevoir une mémoire de type FIFO n'étant pas très compliqué, comme on l’a vu dans le chapitre dédié à ce type de mémoires. Et on peut dire que dans le cas d'un cache, l'implémentation est encore plus simple et se contente d'un seul registre/compteur. Typiquement, il suffit d'ajouter un registre qui mémorise où se situe la donnée la plus récente. Toute insertion d'une nouvelle donnée se fait à l'adresse suivante, ce qui demande juste d'incrémenter le registre avant d'utiliser son contenu pour l'accès mémoire. [[File:Algorithme FIFO de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme FIFO de remplacement des lignes de cache.]] Cet algorithme possède une petite particularité sur les caches associatifs par voie : en augmentant le nombre d'ensembles, les performances peuvent se dégrader : c'est ce qu'on appelle l''''anomalie de Bélády'''. ===MRU : most recently used=== Avec l'algorithme MRU, la donnée remplacée est celle qui a été utilisée le plus récemment. Cet algorithme s'implémente simplement avec un registre, dans lequel on place le numéro de la dernière ligne de cache utilisée. Cet algorithme de remplacement est très utile quand un programme traverse des tableaux du premier élément jusqu'au dernier : les données du tableau sont rarement réutilisées, rendant le cache inutile. Il est prouvé que dans ces conditions, l'algorithme MRU est optimal. Mais dans toutes les autres conditions, cet algorithme a des performances assez misérables. ===LFU : least frequently used=== Avec l'algorithme LFU, la donnée supprimée est celle qui est utilisée le moins fréquemment. Cet algorithme s'implémente en associant un compteur à chaque ligne de cache, qui est incrémenté à chaque accès mémoire. La ligne la moins récemment utilisée est celle dont le compteur associé a la plus petite valeur. Implémenter cet algorithme prend pas mal de transistors, car il faut rajouter autant de compteurs qu'il y a de lignes de cache, en plus d'un circuit pour comparer les compteurs et d'un encodeur. [[File:Algorithme LFU de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme LFU de remplacement des lignes de cache]] ===LRU : least recently used=== Avec l'algorithme LRU, la donnée remplacée est celle qui a été utilisée le moins récemment. Cet algorithme se base sur le principe de localité temporelle, qui stipule qu'une donnée accédée récemment a de fortes chances d'être réutilisée dans un futur proche. Et inversement, la donnée la moins récemment utilisée du cache est celle qui a le plus de chance de ne servir à rien dans le futur. Autant la supprimer en priorité pour faire de la place à des données potentiellement utiles. Implémenter l'algorithme LRU peut se faire de différentes manières, qui ont pour point commun d'enregistrer les accès au cache pour en déduire la ligne la moins récemment accédée. La manière la plus simple demande d'utiliser un compteur pour chaque ligne de mémoire cache, un peu comme le LFU. La différence avec le LFU est que le compteur n'est pas incrémenté lors d'un accès mémoire. À la place, ce compteur est incrémenté régulièrement, chaque incrémentation ayant lieu en même temps pour tous les compteurs. Quand un bloc est chargé dans le cache, ce compteur est mis à zéro. Quand une ligne de cache doit être remplacée, un circuit va vérifier la valeur de tous les compteurs : la ligne LRU (la moins récemment utilisée), est celle dont le compteur a la valeur la plus haute. Le circuit est composé d'un paquet de comparateurs, et d'un encodeur, comme pour l'agorithme LFU. ===Les approximations du LRU=== Implémenter le LRU demande un nombre de transistors proportionnel au carré du nombre de lignes de cache. Autant dire que le LRU devient impraticable sur de gros caches. Ce qui fait que les processeurs modernes implémentent des variantes du LRU, moins couteuses en transistors, qui donnent un résultat approximativement semblable au LRU. En clair, ils ne sélectionnent pas toujours la ligne de cache la moins récemment utilisée, mais une ligne de cache parmi les moins récemment utilisées. Ce n'est pas un problème si grave que cela car les lignes les moins récemment utilisées ont toutes assez peu de chance d'être utilisées dans le futur. Entre choisir de remplacer une ligne qui a 0,5 % de chances d'être utilisée dans le futur et une autre qui a une chance de seulement 1 %, la différence est négligeable en termes de taux de succès. Mais les gains en termes de circuits ou de temps d'accès au cache de ces algorithmes sont très intéressants. L'algorithme le plus simple consiste à couper le cache (ou chaque voie s'il est associatif) en plusieurs sections. L'algorithme détermine la section la moins récemment utilisée, avant de choisir aléatoirement une ligne de cache dans cette section. Pour implémenter cet algorithme, il nous suffit d'un registre qui mémorise le morceau le moins récemment utilisé, et d'un circuit qui choisit aléatoirement une ligne de cache. Cette technique s'adapte particulièrement bien avec des caches associatifs à voies : il suffit d'utiliser autant de morceaux que de voies. Autre algorithme, un peu plus efficace : le '''pseudo-LRU de type M'''. Cet algorithme attribue un bit à chaque ligne de cache, bit qui sert à indiquer de façon approximative si la ligne de cache associée est une candidate pour un remplacement ou non. Il vaut 1 si la ligne n'est pas une candidate pour un remplacement et zéro sinon. Le bit est mis à 1 lorsque la ligne de cache associée est lue ou écrite. Évidemment, au fil du temps, toutes les lignes du cache finiront par avoir leur bit à 1. Lorsque cela arrive, l'algorithme remet tous les bits à zéro, sauf pour la dernière ligne de cache accédée. L'idée derrière cet algorithme est d'encercler la ligne de cache la moins récemment utilisée au fur et à mesure des accès. L'encerclement commence lorsque l'on remet tous les bits associés aux lignes de cache à 0, sauf pour la ligne accédée en dernier. Au fur et à mesure des accès, l'étau se resserre autour de la ligne de cache la moins récemment utilisée. Après un nombre suffisant d'accès, l'algorithme donne une estimation particulièrement fiable. Et comme les remplacements de lignes de cache sont rares comparés aux accès aux lignes, cet algorithme finit par donner une bonne estimation avant qu'on ait besoin d'effectuer un remplacement. Le dernier algorithme d'approximation, le '''PLURt''', se base sur ce qu'on appelle un arbre de décision. Il a besoin de n − 1 bits pour déterminer la ligne LRU. Ces bits doivent être organisés en arbre, comme illustré plus bas. Chacun de ces bits sert à dire : le LRU est à ma droite ou à ma gauche : il est à gauche si je vaux 0, et à droite si je vaux 1. Trouver le LRU se fait en traversant cet arbre, et en interprétant les bits un par un. Au fur et à mesure des lectures, les bits sont mis à jour dans cet arbre, et pointent plus ou moins bien sur le LRU. La mise à jour des bits s'effectue lors des lectures et écritures : quand une ligne est lue ou écrite, elle n'est pas la ligne LRU. Pour l'indiquer, les bits à 1 qui pointent vers la ligne de cache sont mis à 0 lors de la lecture ou écriture. {| |[[File:Organisation des bits avec l'algorithme PLURt.jpg|vignette|Organisation des bits avec l'algorithme PLURt.]] |[[File:Ligne de cache pointée par les bits de l'algorithme.png|vignette|Ligne de cache pointée par les bits de l'algorithme.]] |} ===LRU amélioré=== L'algorithme LRU, ainsi que ses variantes approximatives, sont très efficaces tant que le programme respecte relativement bien la localité temporelle. Par contre, Le LRU se comporte assez mal dans les circonstances ou la localité temporelle est mauvaise mais où la localité spatiale est respectée, le cas le plus emblématique étant le parcours d'un tableau. Pour résoudre ce problème, des variantes du LRU existent. Une variante très connue, l''''algorithme 2Q''', utilise deux caches : un cache FIFO pour les données accédées une seule fois et un second cache LRU. Évidemment, les données lues une seconde fois sont migrées du cache FIFO vers le cache LRU, ce qui n'est pas très pratique. Les processeurs n'utilisent donc pas cette technique, mais celle-ci est utilisée dans les caches de disque dur. D'autres variantes du LRU combinent plusieurs algorithmes à la fois et vont choisir lequel de ces algorithmes est le plus adapté à la situation. Notre cache pourra ainsi détecter s’il vaut mieux utiliser du MRU, du LRU, ou du LFU suivant la situation. ==Les écritures dans le cache : gestion et optimisations== Les écritures se font à une adresse mémoire bien précise, qui peut ou non être chargée dans le cache. Si la donnée à écrire est chargée dans le cache, elle est modifiée directement dans le cache, mais elle ne l'est pas forcément en mémoire RAM. Suivant le processeur, les écritures sont ou non propagées en mémoire RAM. Il existe deux stratégies d'écritures, appelées respectivement le ''write-back'' et le ''write-through''. Avec un cache ''write-back'', si la donnée à mettre à jour est présente dans le cache, on écrit dans celui-ci sans écrire dans la mémoire RAM. Dans ces conditions, une donnée n'est enregistrée en mémoire que si celle-ci quitte le cache, ce qui évite de nombreuses écritures mémoires inutiles. [[File:Cache write-through.png|centre|vignette|upright=2|Cache write-through.]] Avec les caches '''Write-Through''', toute écriture dans le cache est propagée en RAM. Cette stratégie augmente le nombre d'écritures dans la mémoire RAM, ce qui peut saturer le bus reliant le processeur à la mémoire. Les performances de ces caches sont donc légèrement moins bonnes que pour les caches ''write back''. Par contre, ils sont utiles dans les architectures avec plusieurs processeurs, comme nous le verrons dans les chapitres sur les architectures multiprocesseurs. [[File:Cache write-back.png|centre|vignette|upright=2|Cache write-back.]] ===Les caches ''Write-through''=== Sans optimisation particulière, on ne peut écrire dans un cache ''write-through'' pendant qu'une écriture en RAM a lieu en même temps : cela forcerait à effectuer deux écritures simultanées, en comptant celle imposée par l'écriture dans le cache. Pour éviter cela, certains caches ''write-through'' intègrent un '''tampon d’écriture''', qui sert de file d'attente pour les écritures en RAM. C'est une mémoire FIFO dans laquelle on place temporairement les données à écrire en RAM, où elles attendent en attendant que la RAM soit libre. Grâce à lui, le processeur peut écrire dans un cache même si d'autres écritures sont en attente dans le tampon d'écriture. Par souci d'efficacité, des écritures à la même adresse en attente dans le tampon d’écriture sont fusionnées en une seule. Cela fait un peu de place dans le tampon d’écriture, et lui permet d'accumuler plus d'écritures avant de devoir bloquer le cache. Il est aussi possible de fusionner des écritures à adresses consécutives de la mémoire en une seule écriture en rafales. Dans les deux cas, on parle de '''combinaison d'écriture'''. Mais la technique du tampon d'écriture a cependant un léger défaut qui se manifeste dans une situation bien précise : quand le processeur veut lire une donnée en attente dans le tampon d’écriture. La première manière de gérer cette situation est de mettre en attente la lecture tant que la donnée n'a pas été écrite en mémoire RAM. On peut aussi lire la donnée directement dans le tampon d'écriture, cette optimisation portant le nom de '''''store-to-load forwading'''''. Dans tous les cas, il faut détecter le cas où une lecture accède à une donnée dans le tampon d'écriture. À chaque lecture, l'adresse à lire est envoyée au tampon d'écriture, qui vérifie si une écriture en attente se fait à cette adresse. Pour cela, le tampon d’écriture doit être un cache, dont chaque entrée mémorise une écriture. Chaque ligne de cache contient la donnée à écrire, et le tag de la ligne de cache contient l'adresse où écrire la donnée. Notons que cache d'écriture a une politique de remplacement de type FIFO, le tampon d'écriture non-optimisé étant une mémoire FIFO. ===Les caches ''Write-back''=== Les caches ''write-back'' ont beau avoir des performances supérieures à celles des caches ''write-through'', il existe des optimisations qui permettent d'améliorer leurs performances. Ces optimisations consistent à ajouter des caches spécialisés à côté du cache proprement dit. Ces caches permettent de mémoriser des données qui sont éliminées du cache par les algorithmes de remplacement de ligne cache, sans pour autant faire une écriture en RAM. En suivant la procédure habituelle de remplacement des lignes de cache, on doit rapatrier la ligne en RAM avant d'en charger une nouvelle. On peut améliorer la situation en faisant l'inverse : on charge la nouvelle ligne pendant que l'ancienne donnée est rapatriée en RAM. Ainsi, la nouvelle ligne est disponible plus tôt pour le processeur, diminuant son temps d'attente. Pour implémenter cette technique, on doit mémoriser l'ancienne ligne de cache temporairement dans un '''cache d’éviction''' (ou ''write-back buffer''). [[File:Cache d’éviction.png|centre|vignette|upright=2|Cache d’éviction]] Les caches directement adressés ou associatifs par voie possèdent aussi un tampon d’écriture amélioré. Pour limiter les défauts par conflit de ces caches, des scientifiques ont eu l'idée d'insérer un cache pour stocker les données virées du cache. En faisant ainsi, si une donnée est virée du cache, on peut alors la retrouver dans ce cache spécialisé. Ce cache s'appelle le '''cache de victime'''. Ce cache de victime est géré par un algorithme de suppression des lignes de cache de type FIFO. Petit détail : ce cache utilise un tag légèrement plus long que celui du cache directement adressé au-dessus de lui. L'index de la ligne de cache doit en effet être contenu dans le tag du cache de victime, pour bien distinguer deux adresses différentes, qui iraient dans la même ligne du cache juste au-dessus. [[File:Victim Cache Implementation Example.svg|centre|vignette|upright=1|Cache de victime.]] ===La configuration du fonctionnement du cache=== Sur de nombreux processeurs, il est possible de configurer la mémoire cache pour qu'elle fonctionne soit en mode ''write-back'', soit en mode ''write-through''. Pour cela, les processeurs modernes incorporent des '''registres de configuration du cache'''. Le terme ''registre de configuration du cache'' est assez transparent et indique bien quel est leur rôle. Ils configurent comment le cache est utilisé et permettent notamment de configurer le cache pour dire s'il doit fonctionner en mode ''write-back'' ou ''write-through''. Ils permettent aussi d'activer ou de désactiver la combinaison sur écriture. Les registres en question sont configurés soit par le BIOS, soit par le système d'exploitation. Ce sont des registres protégés, que les applications ne peuvent pas configurer, elles n'en ont pas le droit. Typiquement, ils ne sont accessibles en écriture qu'en mode noyau. Sur les processeurs x86, les registres de configuration du cache sont appelés des '''''Memory type range registers''''' (''MTRRs''). Les MTRRs sont assez nombreux, et il y a notamment une différence entre mode réel et protégé. Si vous vous souvenez des chapitres sur le mode d'adressage et la mémoire virtuelle, vous vous souvenez que les processeurs x86 incorporent plusieurs modes de fonctionnement. En mode réel, le processeur ne peut adresser qu'un mébioctet de RAM, avec un système de segmentation particulier. En mode protégé, le processeur peut adresser toute la mémoire et la segmentation fonctionne différemment, quand elle n'est pas simplement désactivée. Les MTRRs sont séparés en deux : ceux pour le mode réel, ceux pour le mode protégé. Les MTRRs fixes sont ceux qui configurent le cache en mode réel, ils étaient utilisés pour gérer l'accès au BIOS, à la mémoire VGA de la carte graphique, et quelques autres accès aux entrées-sorties basiques gérées nativement par le BIOS. Pour le mode protégé, les processeurs au-delà du 386 incorporent des MTRRs variables, qui servent pour les autres entrées-sorties en général, notamment les périphériques PCI, la mémoire vidéo de la carte graphique, et j'en passe. De nos jours, les registres de configuration du cache sont désuets et cette fonctionnalité est gérée directement par la mémoire virtuelle. La table des pages contient, pour chaque page mémoire, des bits de contrôle qui disent si la page mémoire est cacheable ou non. Le contournement de cache est alors géré par le système de mémoire virtuelle, le cache de TLB et tout ce qui va avec. ===L’allocation sur écriture=== Que faire quand une écriture modifie une donnée qui n'est pas dans le cache ? Doit-on écrire la donnée dans le cache, ou non ? Si la donnée est écrite dans le cache, on dit que le cache fait une '''allocation sur l'écriture''' (ou ''write-allocate''). Certains caches effectuent une telle allocation sur écriture, mais d'autres ne le font pas ou du moins pas systématiquement. L’allocation sur écriture peut se décliner en deux sous-catégories : le '''chargement à la demande''' et l''''écriture immédiate'''. Dans le premier cas, on charge la donnée à modifier dans le cache, et on la remplace avec la donnée écrite. Dans l'écriture immédiate, l'écriture a lieu directement dans le cache et la donnée à modifier n'est pas chargée dans le cache. Évidemment, seule une portion de la ligne de cache contient la donnée écrite (valide), et le reste contient des données invalides. Le cache doit savoir quelles sont les portions du cache qui sont valides : cela demande d'utiliser un ''sector cache''. [[File:Write-back with write-allocation.svg|centre|vignette|upright=2|Cache Write-back avec allocation sur écriture.]] Sans allocation sur écriture, l'écriture est transférée directement aux niveaux de cache inférieurs ou à la mémoire si la donnée à modifier n'est pas dans le cache. Certains caches de ce genre utilisent une petite optimisation : lors de toute écriture, ils supposent que l'écriture donnera un succès de cache. Si c'est le cas, la ligne de cache qui contient la donnée est mise à jour avec la donnée à écrire. Mais si ce n'est pas le cas, la ligne de cache est invalidée, et l'écriture est transférée directement à la mémoire ou aux niveaux de cache inférieurs. [[File:Write-through with no-write-allocation.svg|centre|vignette|upright=2|Cache Write-through sans allocation sur écriture.]] ===La cohérence des caches=== Il arrive parfois que la mémoire d'un ordinateur soit mise à jour, sans que les modifications soient répercutées dans les mémoires cache. Dans ce cas, le cache contient une donnée périmée. Or, un processeur doit toujours éviter de se retrouver avec une donnée périmée et doit toujours avoir la valeur correcte dans ses caches : cela s'appelle la '''cohérence des caches'''. Il est possible de se retrouver avec des valeurs périmées dans le cache sur les ordinateurs avec plusieurs processeurs, ou si un périphérique écrit en RAM, les modifications ne sont pas répercutées automatiquement dans les mémoires cache. Pour résoudre ce problème, on peut interdire de charger dans le cache des données stockées dans les zones de la mémoire dédiées aux périphériques. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires cache. Autre solution : utiliser le fait que les périphériques déclenchent une interruption matérielle pour laisser le contrôleur DMA accéder à la mémoire. Dans ce cas, il suffit de vider les caches à chaque interruption matérielle. Le processeur peut le faire automatiquement, ou fournir des instructions pour. ==Le ''cache bypassing'' : contourner le cache== Dans certaines situations, le cache n'est pas utilisé pour certains accès mémoire. Diverses techniques permettent en effet d'effectuer des accès mémoire qui contournent le cache, qui ne passent pas par le cache. Ils sont utilisés quand l'accès en cache fait que des instructions normales ne fonctionnent pas. Par exemple, de tels accès directs à la RAM sont notamment utilisés pour l'implémentation d'instructions atomiques, une classe d'instructions spécifiques utilisées sur les processeurs multicœurs, dont nous parlerons dans plusieurs chapitres. Mais ils sont aussi utilisés pour l'accès aux périphériques, ce que nous allons voir maintenant. ===Accéder aux périphériques demande de contourner le cache=== Pour rappel, un périphérique (au sens d'entrée-sortie) contient des registres d’interfaçage qui ont une adresse au même titre que les cases mémoire. Un périphérique peut à tout instant modifier ses registres d’interfaçage, ce qui se répercute automatiquement dans l'espace d'adressage, mais rien de tout cela n'est transmis au cache. Si les accès aux périphériques passaient par l'intermédiaire du cache, on aurait droit à des problèmes. On aurait encore une fois droit à des problèmes de cohérence des caches. Le problème est géré différemment suivant que l'on utilise un espace d'adressage séparé ou des entrées-sorties mappées en mémoire. La solution est que les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache. Cela demande d'adapter le cache et le processeur. L'implémentation exacte dépend de comment sont adressés les périphériques. Pour rappel, il y a deux solutions pour adresser les périphériques : soit les périphériques disposent d'un espace d'adressage séparé de celui de la mémoire, soit il y un espace d'adressage unique partagé entre processeur et mémoire. Les deux cas donnent des solutions différentes. Avec un espace d'adressage séparé, l'espace d'adressage des périphériques n'est pas caché : aucun accès dans cet espace d'adressage ne passe par le cache. La mémoire cache n'est utilisée que pour l'espace d'adressage des mémoires, rien d'autre. C'est de loin le cas le plus simple : il suffit de concevoir le processeur pour. Il dispose d'instructions séparées pour les accès aux registres d’interfaçage et à la RAM/ROM, les premières ne passent pas par le cache, les autres si. Avec des entrées-sorties mappées en mémoire, la même solution est utilisée, mais dans une version un peu différente. Là encore, les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache, si on veut qu'ils marchent comme ils le doivent. Cela demande d'adapter le cache et le matériel pour que accès aux périphériques mappés en mémoire contournent le cache. Des adresses, voire des zones entières de la mémoire, sont marquées comme étant non-cachables. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires caches. Là encore, le processeur doit être prévu pour : on doit pouvoir le configurer de manière à marquer certaines zones de la RAM comme non-cacheable. Reste qu'il faut marquer des régions de la RAM comme non-cacheable. Pour cela, on améliore les registres de configuration du cache, vus plus haut, afin qu'ils permettent de configurer certaines portions de la RAM pour préciser qu'elles ne doivent pas être mises en cache, qu'il faut activer le contournement de cache pour celles-ci. ===Contourner le cache pour des raisons de performance=== Il arrive que des données avec une faible localité soient chargées dans le cache inutilement. Or, il vaut mieux que ces données transitent directement entre le processeur et la mémoire, sans passer par l'intermédiaire du cache. Pour cela, le processeur peut fournir des instructions d'accès mémoire qui ne passent pas par le cache, à côté d'instructions normales. De telle instructions sont appelées des '''instructions mémoire non-temporelles'''. Non-temporelle, dans le sens : pas de localité temporelle (c.a.d que les données ne seront pas réutilisées plus tard). Mais il existe aussi des techniques matérielles, où le cache détecte à l'exécution les lectures qui gagnent à contourner le cache. La dernière méthode demande d'identifier les instructions à l'origine des défauts de cache, le processeur accédant directement à la RAM quand une telle instruction est détectée. Si une instruction d'accès mémoire fait trop de défauts de cache, c'est signe qu'elle gagne à contourner le cache. L'idée est de mémoriser, pour chaque instruction d'accès mémoire, un historique de ses défauts de cache. Il existe plusieurs méthodes pour cela, mais toutes demandent d'ajouter de quoi mémoriser l'historique des défauts de cache des instructions. L'historique est mémorisé dans une mémoire appelée la '''table d’historique des défauts de lecture''' (''load miss history table''), qui est souvent un cache. L'historique en question est, dans sa version la plus simple, un compteur de quelques bits incrémenté à chaque succès de cache et décrémenté à chaque défaut de cache, qui indique si l'instruction a en moyenne fait plus de défauts ou de succès de cache. La table associe le ''program counter'' d'une instruction mémoire à cet historique. À la première exécution d'une instruction d'accès mémoire, une entrée de cette table est réservée pour l'instruction. Lors des accès ultérieurs, le processeur récupérer les informations associées et décide s'il faut contourner le cache ou non. ==La hiérarchie mémoire des caches== [[File:Cache Hierarchy.png|vignette|Hiérarchie de caches]] On pourrait croire qu'un seul cache est largement suffisant pour compenser la lenteur de la mémoire. Hélas, les processeurs sont devenus tellement rapides que les caches sont eux-mêmes très lents ! Pour rappel, plus une mémoire peut contenir de données, plus elle est lente. Et les caches ne sont pas épargnés. Si on devait utiliser un seul cache, celui-ci serait très gros et donc trop lent. La situation qu'on cherche à éviter avec la mémoire RAM revient de plus belle. Même problème, même solution : si on a décidé de diviser la mémoire principale en plusieurs mémoires de taille et de vitesse différentes, on peut bien faire la même chose avec la mémoire cache. Depuis environ une vingtaine d'années, un processeur contient plusieurs caches de capacités très différentes : les caches L1, L2 et parfois un cache L3. Certains de ces caches sont petits, mais très rapides : c'est ceux auxquels on va accéder en priorité. Viennent ensuite d'autres caches, de taille variable, mais plus lents. Les processeurs ont donc une hiérarchie de caches qui se fait de plus en plus complexe avec le temps. Cette hiérarchie est composée de plusieurs niveaux de cache, qui vont des niveaux inférieurs proches de la mémoire RAM à des niveaux supérieurs proches du processeur. Plus on monte vers les niveaux supérieurs, plus les caches sont petits et rapides. Un accès mémoire dans une hiérarchie de cache fonctionne comme suit : on commence par vérifier si la donnée recherchée est dans le cache le plus rapide, à savoir le cache L1. Si c'est le cas,n on la charge depuis ce cache directement. Si elle n’y est pas, on vérifie si elle est dans le cache de niveau supérieur, le cache L2. Et rebelote ! Si elle n'y est pas, on vérifie le cache du niveau supérieur. Et on répète cette opération, jusqu’à avoir vérifié tous les caches. Si la donnée n'est dans aucun cache, on doit alors aller chercher la donnée en mémoire. [[File:Hiérarchie de caches.png|centre|vignette|upright=2|Hiérarchie de caches]] Il y a des différences assez notables entre chaque niveau de cache. Par exemple, les différents niveaux de cache n'ont pas forcément les mêmes politiques de remplacement des lignes de cache. Le cache L1 a généralement une politique de remplacement simple, très rapide, mais peu efficace. De même, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Le cache le plus proche de la mémoire est appelé le '''cache de dernier niveau''', ''Last Level Cache'' en anglais. Il a parfois des caractéristiques totalement différentes des autres caches. Par exemple, sur les processeurs multicoeurs, le cache L3 n'est techniquement pas dans le cœur, mais fait partie d'un ensemble de circuits reliés, comme le contrôleur mémoire ou l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc. ===Les caches exclusifs et inclusifs=== Notons que du point de vue de cette vérification, il faut distinguer les caches inclusifs et exclusifs. Avec les caches inclusifs, si une donnée est présente dans un cache, alors elle est présente dans les caches des niveaux inférieurs, ce qui implique l'existence de données en doublon dans plusieurs niveaux de cache. À l'opposé, les caches exclusifs font que toute donnée est présente dans un seul cache, pas les autres. Il existe aussi des caches qui ne sont ni inclusifs, ni exclusifs. Sur ces caches, chaque niveau de cache gère lui-même ses données, sans se préoccuper du contenu des autres caches. Pas besoin de mettre à jour les niveaux de cache antérieurs en cas de mise à jour de son contenu, ou en cas d'éviction d'une ligne de cache. La conception de tels caches est bien plus simple. Dans les '''caches exclusifs''', le contenu d'un cache n'est pas recopié dans le cache de niveau inférieur. Il n'y a pas de donnée en double et on utilise 100 % de la capacité du cache, ce qui améliore le taux de succès. Par contre, le temps d'accès est un peu plus long. La raison est que si une donnée n'est pas dans le cache L1, on doit vérifier l'intégralité du cache L2, puis du cache L3. De plus, assurer qu'une donnée n'est présente que dans un seul cache nécessite aux différents niveaux de caches de communiquer entre eux pour garantir que l'on a pas de copies en trop d'une ligne de cache, ce qui peut prendre du temps. [[File:Caches exclusifs.png|centre|vignette|upright=2|Caches exclusifs]] Dans le cas des '''caches inclusifs''', le contenu d'un cache est recopié dans les caches de niveau inférieur. Par exemple, le cache L1 est recopié dans le cache L2 et éventuellement dans le cache L3. Ce genre de cache a un avantage : le temps d'accès à une donnée est plus faible. La raison est qu'il ne faut pas vérifier tout un cache, mais seulement la partie qui ne contient pas de donnée en doublon. Par exemple, si la donnée voulue n'est pas dans le cache L1, on n'est pas obligé de vérifier la partie du cache L2 qui contient la copie du L1. Ainsi, pas besoin de vérifier certaines portions du cache, ce qui est plus rapide et permet de simplifier les circuits de vérification. En contrepartie, l'inclusion fait que qu'une partie du cache contient des copies inutiles, comme si le cache était plus petit. De plus, maintenir l'inclusion est compliqué et demande des circuits en plus et/ou des échanges de données entre caches. [[File:Caches inclusifs.png|centre|vignette|upright=2|Caches inclusifs]] Maintenir l'inclusion demande de respecter des contraintes assez fortes, ce qui ne se fait pas facilement. Premièrement, toute donnée chargée dans un cache doit aussi l'être dans les caches de niveau inférieur. Ensuite, quand une donnée est présente dans un cache, elle doit être maintenue dans les niveaux de cache inférieurs. De plus, toute donnée effacée d'un cache doit être effacée des niveaux de cache supérieurs : si une donnée quitte le cache L2, elle doit être effacée du L1. Ces trois contraintes posent des problèmes si chaque cache décide du remplacement des lignes de cache en utilisant un algorithme comme LRU, LFU, MRU, ou autre, qui utilise l'historique des accès. En effet, dans ce cas, le cache décide de remplacer les lignes de cache selon l'historique des accès, historique qui varie suivant chaque niveau de cache. Par exemple, une donnée rarement utilisée dans le L2 peut parfaitement être très fréquemment utilisée dans le L1 : la donnée sera alors remplacée dans le L2, mais sera maintenue dans le L1. On observe aussi des problèmes quand il existe plusieurs caches à un seul niveau : chaque cache peut remplacer les lignes de cache d'une manière indépendante des autres caches du même niveau, donnant lieu au même type de problème. Pour maintenir l'inclusion, les caches doivent se transmettre des informations qui permettent de maintenir l'inclusion. Par exemple, les caches de niveaux inférieurs doivent prévenir les niveaux de cache supérieurs quand ils remplacent une ligne de cache. De plus, toute mise à jour dans un cache doit être répercutée dans les niveaux de cache inférieurs et/ou supérieurs. On doit donc transférer des informations de mise à jour entre les différents niveaux de cache. Généralement, le contenu des caches d'instruction n'est pas inclus dans les caches de niveau inférieurs, afin d'éviter que les instructions et les données se marchent sur les pieds. Enfin, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Dans ce cas, l'inclusion est plus difficile à maintenir, pour des raisons assez techniques. ===Les caches eDRAM, sur la carte mère et autres=== D'ordinaire, les mémoires caches sont intégrées au processeur, à savoir que cache et CPU sont dans le même circuit imprimé. Les caches sont donc fabriqués avec de la SRAM, seule forme de mémoire qu'on peut implémenter dans un circuit intégré. Intégrer tous les caches dans le processeur est une solution et efficace. Mais certains processeurs ont procédé autrement. [[File:Cache-on-a-stick module.jpg|vignette|Cache-on-a-stick module]] Des processeurs assez anciens incorporaient un cache L1 dans le processeur, mais plaçaient un cache L2 sur la carte mère. Le cache était clippé sur un connecteur sur la carte mère, un peu comme le sont les barrettes de mémoire. On parlait alors de '''''Cache on a stick''''' (COAST). On aurait pu s'attendre à ce que de tels caches soient en DRAM, vu qu'ils sont placés sur des barrettes de RAM, mais la ressemblance avec la mémoire RAM principale s'arrête là. Le cache était fabriqué en mémoire SRAM, même s'il est en théorie possible de faire de tels caches avec de la DRAM. Les premiers processeurs avec un cache faisaient ainsi, au début des années 90. Il a été introduiot sur les processeurs Motorola, et a été utilisé sur les IBM PC et les Macintosh de l'époque. Les ordinateurs Macintosh utilisaient de tels caches, pour la pluaprt des modèles. Pour ce qui est des PC, les premiers processeurs x86 faisaient pareil, notamment les processeurs Intel. Le 486, le Pentium et le Pentium 2 utilisaient des ''Cache on a stick''. L'avantage est que cela permettait de mettre plus de cache, à une époque où les circuits étaient limités en transistors. De plus, cela permettait au consommateur de choisir quelle quantité de cache il voulait, selon ses finances. Il était possible de laisser le processeur fonctionner soit sans mémoire cache, soit avec un cache de 256 Kibioctets, de 512 Kibioctets, etc. Il était possible d'upgrader le cache si besoin. Pour les CPU Intel, le cache était connecté sur le bus système, au même titre que la mémoire RAM et les entrées-sorties. Il faut dire que les processeurs de l'époque utilisaient un bus système et n'avaient pas de bus mémoire dédié. Mais en théorie, rien n’empêche de connecter le cache sur un bus mémoire dédié. Toujours est-il que les lectures et écritures étaient propagées à la fois dans le cache et la RAM. Les écritures se faisaient dans les deux, systématiquement dans la RAM, mais aussi dans le cache en cas de succès de cache. Les lectures étaient servies soit par le cache en cas de succès de cache, soit par la RAM en cas de défaut de cache. Si le cache répondait en premier, la transaction sur le bus se terminait précocement et l'accès en RAM était abandonné. [[File:Intel486 Иерархия памяти.png|centre|vignette|upright=2.5|Intel486 : le cache était connecté sur le bus système.]] À l'inverse, certains processeurs possédaient un cache fabriqué en mémoire DRAM, et plus précisément avec de la mémoire eDRAM. Le cache n'était pas intégré dans le même circuit imprimé que le processeur, mais profitait d'une architecture en ''chiplet''. Pour rappel, cela veut dire que le processeur est en réalité composé de plusieurs circuits intégré séparés, mais interconnectés et soudés sur un même PCB carré. Avec un cache en eDRAM, le cache avait son propre circuit intégré, séparé du circuit intégré du processeur ou du circuit intégré pour le contrôleur mémoire/IO. Un exemple est celui du cache des processeurs Intel de microarchitecture Broadwell, vus dans ce chapitre dans la section sur les caches splittés. Les tags étaient intégrés dans le circuit intégré du processeur, mais les données étaient mémorisées dans une puce d'eDRAM séparée. La puce eDRAM correspondait en réalité à une DRAM adressable qui servait de DRAM pour les données et mémorisaient les voies du cache. ==Les caches adressés par somme et hashés== Les caches adressés par somme sont optimisés pour incorporer certains calculs d'adresse directement dans le cache lui-même. Pour rappel, certains modes d'adressage impliquent un calcul d'adresse, qui ajoute une constante à une adresse de base. Généralement, l'adresse de base est l'adresse d'un tableau ou d'une structure, et la constante ajoutée indique la position de la donnée dans le tableau/la structure. Les caches hashés et les caches adressés par somme permettent de faire l'addition directement dans la mémoire cache. Voyons d'abord les caches hashés, avant de passer aux caches adressés par somme. Sur les '''caches hashés''', l'addition est remplacée par une autre opération, par exemple des opérations bit à bit du style XOR, AND ou OR, etc. Seulement, utiliser des opérations bit à bit pose un problème : il arrive que deux couples Adresse/décalage donnent le même résultat. Par exemple, le couple Adresse/décalage 11101111/0001 donnera la même adresse que le couple 11110000/0000. Dit autrement, deux adresses censées être différentes (après application du décalage) sont en réalité attribuées à la même ligne de cache. Il est toutefois possible de gérer ces situations, mais cela demande des astuces de haute volée pour faire fonctionner la mémoire cache correctement. Sur les '''caches adressés par somme''', le décodeur est modifié pour se passer de l'addition. Pour comprendre comment, il faut rappeler qu'un décodeur normal est composé de comparateurs, qui vérifient si l'entrée est égale à une constante bien précise. Sur un cache ordinaire, l'addition est faite séparément du décodage des adresses par le cache, dans l'unité de calcul ou dans l'unité de génération d'adresse. [[File:Non sum adressed cache.png|centre|vignette|upright=2|Cache normal.]] Mais les caches adressés par somme modifient le décodeur, qui est alors composé de comparateurs qui testent si la somme adresse + décalage est égale à une constante. [[File:Cache adressé par somme.png|centre|vignette|upright=2|Cache adressé par somme.]] Chaque circuit du décodeur fait le test suivant, avec K une constante qui dépend du circuit : : <math>A + B = K</math> Ce qui est équivalent à faire le test suivant : : <math>A + B - K = 0</math> En complément à deux, on a <math>- K = \overline{K} + 1</math>. En injectant dans l'équation précédente, on a : : <math>A + B + \overline{K} + 1 = 0</math> En réorganisant les termes, on a : : <math>A + B + \overline{K} = - 1</math> Il suffit d'utiliser un additionneur ''carry-save'' pour faire l'addition des trois termes. Rappelons qu'un tel additionneur fournit deux résultats en sortie : une somme calculée sans propager les retenues et les retenues en question. Notons que les retenues sont à décaler d'un cran, vu qu'elles sont censées s'appliquer à la colonne suivante. En notant la somme S et les retenues R, on a: : <math>S + (R << 1) = - 1 </math>, le décalage d'un cran à gauche étant noté <math><< 1</math>. Ensuite, -1 est codé avec un nombre dont tous les bits sont à 1 en complément à un/deux. : <math>S + (R << 1) = 111 \cdots 111111</math> [[File:Sum + retenue add.png|centre|vignette|upright=2|Sum + retenue add]] Un simple raisonnement nous permet de savoir si le résultat est bien -1, sans faire l'addition <math>S + (R << 1)</math>. En effet, on ne peut obtenir -1 que si la somme est l'inverse des retenues : un 0 dans le premier nombre correspond à un 1 dans l'autre, et réciproquement. En clair, on doit avoir <math>\overline{S} = R << 1</math>. Pour vérifier cela, il suffit de faire un simple XOR entre la somme et les retenues décalées d'un cran. On a alors : : <math>S \oplus (R << 1) = 111 \cdots 111111</math> La comparaison avec -1 se fait avec une porte ET à plusieurs entrées. En effet, la porte donnera un 1 seulement si tous les bits d'entrée sont à 1, ce qui est ce qu'on veut tester. Au final, l'additionneur pour l'addition adresse + décalage est remplacé par un additionneur carry-save suivi d'une couche de portes XOR et d'un comparateur avec une constante, ce qui économise de circuits et améliore les performances. [[File:Final circuit of sum addressed cache.png|centre|vignette|upright=2|Cache adressé par somme.]] En prenant en compte que la constante K est justement une constante, certaines entrées de l'additionneur carry-save sont toujours à 0 ou à 1, ce qui permet quelques simplifications à grand coup d’algèbre de Boole. Chaque additionneur complet qui compose l’additionneur carry-save est remplacée par des demi-additionneurs (ou par un circuit similaire). Autant dire que l'on gagne tout de même un petit peu en rapidité, en supprimant une couche de portes logiques. Le circuit de décodage économise aussi des portes logiques, ce qui est appréciable. ==Les caches à accès uniforme et non-uniforme== Intuitivement, le temps d'accès au cache est le même pour toutes les lignes de cache. Il s'agit de cache appelés '''caches à accès uniforme''', sous-entendu à temps d'accès uniforme. Mais sur les caches de grande capacité, il arrive souvent que le temps de propagation des signaux varie fortement suivant la ligne de cache à lire. D'ordinaire, on se cale sur la ligne de cache la plus lente pour caler la fréquence d'horloge du cache, même si on pourrait faire mieux. Cependant, les '''caches à accès non uniforme''' ont une latence différente pour chaque ligne d'un même cache. Certaines lignes de cache sont plus rapides que d'autres. Niveau terminologie, nous allons parler de caches UCA et NUCA : ''Uniform Access Cache'' pour les caches à accès uniforme, ''Non-Uniform Access Cache'' pour les caches à accès non-uniforme. [[File:Caches UCA et NUCA.png|vignette|Caches UCA et NUCA.]] Les caches NUCA et UCA sont souvent composés de plusieurs banques séparées, typiquement une par voie. Sur les caches UCA, les banques sont interconnectées avec le processeur de manière à ce que toutes les interconnexions ont la même longueur pour toutes les banques. Typiquement, les banques sont organisées en carré, avec les interconnexions qui partent du centre, avec une disposition en H, illustrée ci-contre Mais avec les caches NUCA, ce n'est pas le cas. Les interconnexions sont simplifiées et ont des longueurs différentes. Les caches NUCA n'ont pas tous le même genre d'interconnexions, qui dépendent du cache NUCA. En général, les interconnexion forme un réseau avec des sortes de routeurs qui redirigent les données/commandes vers la bonne destination : cache ou processeur. Les banques plus proches du processeur sont accessibles plus rapidement que celles éloignées, même si la différence n'est pas énorme. Les caches NUCA sont généralement associatifs par voie. Les plus simples utilisent une banque par voie pour le cache, ce qui fait que certaines voies répondent plus vite que les autres. La détection des succès de cache est alors plus rapide si la donnée lue/écrite est dans une voie/banque rapide. En théorie, les défauts de cache demandent de vérifier toutes les banques, et se calent donc sur la pire latence. Mais divers caches se débrouillent pour que ce ne soit pas le cas, soit en vérifiant les banquyes unes par une, soit par un mécanisme de recherche plus complexe. Les caches NUCA sont surtout utilisés pour les caches L3 et L4, éventuellement les caches L2. Les caches L1 sont systématiquement des caches UCA, car la latence de l'accès au cache L1 est utilisée par le processeur pour décider quand lancer les instructions. Pour simplifier, le processeur peut démarrer en avance une instruction avant qu'une opérande soit lue dans le cache L1, de manière à ce que la donnée arrive en entrée de l'ALU pile en même temps que l'instruction. Une histoire d'exécution dans le désordre et d'émission anticipée des instructions qu'on détaillera dans une bonne dizaine de chapitres. Toujours est-il que tout est plus simple pour le processeur si le cache L1 a un temps d'accès fixe. Par contre, les caches L3 et L4 sont traités en attendant que les données arrivent, le processeur reprend l'exécution des instructions quand les caches L3 et L4 ont terminé de répondre, pas avant. Avec l'association une banque = une voie, la correspondance ligne de cache → bloc de mémoire qui est statique : on ne peut pas déplacer le contenu d'une ligne de cache dans une autre portion de mémoire plus rapide suivant les besoins. Mais la recherche académique a étudié le cas où la correspondance entre une ligne de cache et une banque varie à l’exécution. Pour nommer cette distinction, on parle de caches S-NUCA (''Static NUCA'') et D-NUCA (''Dynamic NUCA''). Intuitivement, on s'attend à ce que les caches D-NUCA soient plus performants que les caches S-NUCA. Les lignes de cache les plus utilisées peuvent migrer dans une banque rapide, alors que les lignes de cache moins utilisées vont dans une banque éloignée. Les lignes de cache se répartissent dans le cache dynamiquement dans les banques où elles sont le plus adaptées. Mais paradoxalement, le gain des caches D-NUCA est presque nul, voire insignifiant. La raison est que les caches D-NUCA doivent incorporer un système pour déterminer dans quelle banque se situe la donnée pour détecter les succès/défauts de cache, ainsi qu'un système pour migrer les données entre banques. Et ce système augmente le temps d'accès au cache, réduisant à néant l'intérêt d'un cache D-NUCA. Si on économise quelques microsecondes de temps d'accès en passant d'un cache UCA à un cache S-NUCA, ce n'est pas pour les perdre en passant à un D-NUCA. La majorité des caches D-NUCA sont donc en cours de recherche, mais ne sont pas utilisés en pratique. ==La tolérance aux erreurs des caches== Une mémoire cache reste avant tout une mémoire RAM, bien que ce soit de la SRAM. Elle n'est pas parfaite et est donc sujette à des erreurs, qui peuvent inverser un bit ou l'effacer. De telles erreurs sont liées à des rayons cosmiques très énergétiques, à des particules alpha produites par le packaging ou le métal deu circuit intégré, peu importe : l'essentiel est qu'ils inversent parfois un bit. Les mémoires modernes savent se protéger contre de telles erreurs, en utilisant trois moyens. ===Les mémoires caches ECC et à bit de parité=== Le premier moyen est l'usage de codes correcteurs d'erreurs, qui ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Les bits ajoutés dépendent de la donnée mémorisée dans le byte, et servent à détecter une erreur, éventuellement à la corriger. Le cas le plus simple ajoute un simple bit de parité pour chaque byte et se contente de détecter les erreurs dans les corriger. Les autres codes ECC permettent eux de corriger des erreurs, mais ils demandent d'ajouter au moins deux bits par byte, ce qui a un cout en circuit plus élevé. Un simple bit de parité permet de détecter qu'un bit a été inversé, mais ne permet pas de corriger l'erreur. En soi, ce n'est pas un problème. Si une erreur est détectée, on considère que la ligne de cache est invalide. Le cache gère la situation comme un défaut de cache et va chercher la donnée valide en mémoire RAM. Le cout en circuits est donc faible, mais les défauts de cache sont plus nombreux. Les codes ECC sont eux capables de corriger les erreurs, si elles ne modifient pas trop de bits d'un coup. Par contre, ils utilisent deux à trois bits par octet, ce qui a un cout en circuits loin d'être négligeable. Il y a donc un compromis entre défauts de cache et cout en circuits. La gestion de l'ECC est différente suivant le niveau de cache. Généralement, le cache L1 n'utilise pas l'ECC mais se contente d'un simple bit de parité pour éviter la corruption de ses données. Le cache étant petit, les corruptions de données sont assez rares, et les défauts de cache induits faibles. Il est plus important d'utiliser un code de détection d'erreur simple, rapide, qui ne ralentit pas le cache et n'augmente pas sa latence. Si une ligne de cache est corrompue, il a juste à aller lire la ligne depuis le cache L2, ou un niveau de cache inférieur. Du moins, c'est possible sur le cache en question est un cache inclusif et/ou ''write-through''. Par contre, le niveau de cache L2 et ceux en-dessous utilisent presque systématiquement une mémoire SRAM ECC. La raison principale étant que ce sont des caches assez gros, pour lesquels la probabilité d'une erreur est assez élevée. Plus une mémoire a de bits et prend de la place, plus il y a une chance élevée qu'un bit s'inverse. Et vu que les caches L2/L3/L4 sont par nature plus lents et plus gros, ils peuvent se permettre le cout en performance lié à l'ECC, idem pour le cout en circuit. Sans compter qu'en cas d'erreur, ils doivent aller lire la ligne de cache originelle en mémoire RAM, ce qui est très lent ! Mieux vaut corriger l'erreur sur place en utilisant l'ECC. ===L'usage du ''memory scrubbing'' sur les caches=== La plupart des erreurs ne changent qu'un seul bit dans un byte, mais le problème est que ces erreurs s'accumulent. Entre deux accès à une ligne de cache, il se peut que plusieurs erreurs se soient accumulées, ce qui dépasse les capacités de correction de l'ECC. Dans ce cas, il existe une solution appelée le ''memory scrubbing'', qui permet de résoudre le problème au prix d'un certain cout en performance. Pour rappel, l'idée est de vérifier les lignes de caches régulièrement, pour éviter que les erreurs s'accumulent. Par exemple, on peut vérifier chaque ligne de cache toutes les N millisecondes, et corriger une éventuelle erreur lors de cette vérification. En faisant des vérifications régulières, on garantir que les erreurs n'ont pas le temps de s'accumuler, sauf en cas de malchance avec des erreurs très proches dans le temps. Il ne s'agit pas d'un rafraichissement mémoire, car les SRAM ne s'effacent pas), mais ça a un effet similaire. Et évidemment, le ''memory scrubbing'' a un cout en performance. On peut faire une comparaison avec le rafraichissement mémoire : les rafraichissement réguliers réduisent les performances, car cela fait des accès en plus. Des accès qui sont de plus timés à des instants bien précis qui ne sont pas forcément les plus adéquats. Il est possible qu'un rafraichissement ait lieu en même temps qu'un accès mémoire et le rafraichissement a la priorité, ce qui réduit les performances. La même chose arrive avec les vérifications du ''memory scrubbing''. Malgré tout, la technique a été utilisée sur les caches de certains processeurs commerciaux, dont des processeurs AMD Athlon et Athlon 64. Elle est surtout utilisable sur les caches L2/L3, pour lesquels le cout du pseudo-rafraichissement est acceptable. ==Un exemple de cache : le cache d'instruction== La grande majorité des processeurs utilise deux caches L1 séparés : un '''cache d'instructions''' dédié aux instructions, et un autre pour les données. Une telle organisation permet de charger une instruction tout en lisant une donnée en même temps. Notons que seul le cache L1 est ainsi séparé entre cache de données et d'instructions. Le cache d’instruction se situe en théorie entre l'unité de chargement et l'unité de décodage. En effet, ce cache prend en entrée une adresse et fournit une instruction. L'adresse est fournie par le ''program counter'', l'instruction est envoyée dans l'unité de décodage. Le cache se situe donc entre les deux. Le cache de données L1 est connecté au chemin de données, et notamment aux unités de communication avec la mémoire, pas au séquenceur. [[File:Caches L1 et positions dans le processeur.png|centre|vignette|upright=2.5|Caches L1 et positions dans le processeur]] Les deux caches sont reliés au processeur par des bus séparés, l'ensemble ressemble à une architecture Harvard, mais où les caches remplacent les mémoires RAM/ROM. Le cache d'instruction prend la place de la mémoire ROM et le cache de données prend la place de la mémoire RAM. Évidemment, il y a des niveaux de caches en dessous des caches de données/instruction, et ceux-ci contiennent à la fois données et instructions, les deux ne sont pas séparées dans des mémoires/caches séparés. Raison pour laquelle l'ensemble est appelé une '''architecture Harvard modifiée'''. Architecture Harvard, car l'accès aux données et instructions se font par des voies séparées pour le processeur, modifiée car la séparation n'est effective que pour le cache L1 et pas les autres niveaux de cache, et encore moins la RAM. Sur les processeurs modernes, il arrive très souvent que le processeur doive charger une instruction et lire/écrire une donnée en même temps. Et à vrai dire, c'est la règle plus que l'exception. L'usage d'une architecture Harvard modifiée permet cela très facilement : on peut accéder au cache d'instruction via un bus, et au cache de donnée avec l'autre ===Pourquoi scinder le cache L1 en cache d'instruction et de données=== L'usage d'un cache d’instruction séparé du cache de données est à contraster avec l'usage d'un cache L1 multiport unique, capable de mémoriser à la fois instructions et données. Les deux solutions sont possibles ont été utilisées. Les premiers processeurs avaient un cache L1 unique et multiport, mais ce n'est plus le cas sur les processeurs modernes, car les contraintes ne sont pas les mêmes. Le compromis à faire est celui entre deux petits caches rapides et un gros cache plus lent. Pour rappel, plus un cache est petit, plus il est rapide et chauffe moins. Donc au lieu d'utiliser, par exemple, un gros cache lent de 64 Kibioctets, on utilise deux caches de 32 kibioctets, plus rapides. La capacité totale est la même, mais le temps d'accès plus faible. En termes de temps d'accès, la meilleure solution est celle des deux caches simple port. Mais pour ce qui est de l'économie de circuits, c'est moins évident. Entre deux mémoires simple port et une mémoire multiport, la différence en termes de transistors est ambigüe et dépend de la capacité des caches. La différence est surtout notable pour les gros caches, moins pour les petits caches. Il faut aussi tenir compte de la capacité effective. Avec deux caches séparés, la répartition de la capacité du cache L1 est fixée une bonne fois pour toutes. Par exemple, avec un cache d'instruction de 32 KB et un cache de données de 32 KB, impossible d'allouer 40 KB aux données et 20 aux instructions. Alors qu'avec un cache L1 unique de 64 KB, on pourrait le faire sans soucis. La répartition se fait naturellement, en fonction de la politique de remplacement du cache et est proche de l'optimal. C'est là un désavantage des caches d'instructions/données séparés : une capacité effective moindre. Tout cela explique pourquoi le cache L1 est le seul à être ainsi scindé en deux, avec une séparation entre instructions et données : les contraintes au niveau du cache L1 et L2 ne sont pas les mêmes. Pour les caches L1, le temps d'accès est plus important que la capacité, ce qui favorise les caches séparés. Par contre, pour les caches L2/L3/L4, le temps d'accès n'est pas déterminant, alors que la capacité effective et l'économie en circuits sont significatives. ===La connexion des caches L1 avec le cache L2=== Pour les connexions avec le cache L2, tout dépend du processeur. Certains utilisent un cache L2 multiport, qui permet aux deux caches L1 de lire ou écrire dans le cache L2 simultanément. [[File:Cache d'instructions.png|centre|vignette|upright=1.5|Cache d'instructions.]] Si le cache L2 ne gère pas les accès simultanés, il n'y a qu'un seul bus relié aux caches L1 et au cache L2. On doit effectuer un arbitrage pour décider quel cache a la priorité, chose qui est réalisé par un circuit d'arbitrage spécialisé. [[File:Circuit d'arbitrage du cache.png|centre|vignette|upright=1.5|Circuit d'arbitrage du cache.]] Généralement, les caches d'instructions peuvent se permettre d'être plus petits que les caches de données, car les programmes sont souvent plus petits que les données manipulées. Songez que des programmes de quelques mébioctets peuvent parfois remplir la RAM avec plusieurs gibioctets de données. Lancez votre navigateur internet et ouvrez une page web un peu chargée, pour vous en convaincre ! ===Les spécificités du cache d'instruction : lecture seule, bloquant, etc=== Les instructions sont rarement modifiées ou accédées en écritures, contrairement aux données. Et cela permet d'utiliser un cache simplifié pour les instructions. Autant un cache généraliste doit permettre les lectures et écritures depuis le processeur (avec les échanges avec la RAM), autant un cache d'instruction peut se contenter des lectures provenant du CPU et des échanges avec la RAM. Le cache d'instructions est donc très souvent en « lecture seule » : le processeur ne peut pas écrire dedans, mais juste le lire ou charger des instructions dedans. Un cache d'instruction est donc plus simple qu'un cache pour les données : on peut retirer les circuits en charge de l'écriture (mais on doit laisser un port d'écriture pour charger les instructions dedans). Le gain en circuits permet d'utiliser un cache d'instruction plus gros ou au contraire de laisser de la place pour le cache de données. Le gain en termes de capacité compense alors un peu les inconvénients des caches séparés. Par contre, cela complique la gestion du code automodifiant, c'est-à-dire des programmes dont certaines instructions vont aller en modifier d'autres, ce qui sert pour faire de l'optimisation ou est utilisé pour compresser ou cacher un programme (les virus informatiques utilisent beaucoup de genre de procédés). Quand le processeur exécute ce genre de code, il ne peut pas écrire dans ce cache L1 d'instructions, mais doit écrire dans le cache L2 ou en RAM, avant de recharger les instructions modifiées dans le cache L1. Cela qui prend du temps et peut parfois donner lieu à des erreurs si le cache L1 n'est pas mis à jour. Les algorithmes de remplacement des lignes de cache optimaux pour les données ne le sont pas pour les instructions, de même que la taille optimale du cache, la taille des lignes de cache optimale, ou même les algorithmes de préchargement. Par exemple, pour le remplacement des lignes de cache, un simple algorithme LRU est presque optimal pour les instructions, autant il peut donner de mauvaises performances quand on manipule beaucoup de tableaux. Cela justifie d'utiliser des caches spécialisés pour chacune. On peut adapter le cache d'instruction à son contenu, ce qui le rend plus rapide ou plus petit à performance égale. Les caches d'instructions sont généralement des caches bloquants. Il ne servirait à rien de rendre un cache d'instruction non-bloquant, le cout en circuits ne se traduirait pas par une augmentation significative des performances. À l'opposé, les caches de données sont non-bloquants sur les architectures modernes, pour des raisons de performance. Ce qui rend la séparation assez intéressante, les deux caches ayant des besoins différents et des implémentations différentes, cela permet d'optimiser le cout en transistors des caches. ===L'impact du cache d'instruction sur les performances=== Sur les architectures conventionnelles, le cache d'instruction a plus d'impact sur les performances que le cache de données. La raison principale est que les instructions ont une meilleure localité spatiale et temporelle que pour les données. Pour la localité spatiale, les instructions consécutives se suivent en mémoire, alors que rien ne garantit que des données utilisées ensemble soient regroupées en mémoire. Pour localité temporelle, elle est très variable pour les données, mais très courante pour les instructions du fait de l'usage fréquent des boucles et des fonctions. : La présence de branchements atténue la localité temporelle des instruction, sauf que la majorité des branchements sautent à un endroit très proche, seuls les appels de fonction brisent la localité spatiale. La conséquence est qu'il arrive que certains CPU aient un cache L1 d'instruction plus gros que celui pour les données. On parle alors de '''cache L1 asymétriques'''. Un exemple est celui des processeurs AMD de microarchitecture Zen, dont le cache d'instruction était deux fois plus gros que le cache de données. Leur cache d'instruction faisait 64 kibioctets, contre seulement 32 pour le cache de données. D'ailleurs, il existe des processeurs assez extrêmes qui se contentent d'un cache d'instruction unique, sans cache de données. C'est le cas sur les processeurs vectoriels ou les GPU que nous verrons dans les chapitres de fin de ce wikilivres. De tels processeurs sont spécialisés dans la manipulation de tableaux de données, traitement qui a une faible localité temporelle. En conséquence, utiliser un cache de données n'est pas vraiment utile, voire peu être contreproductif, alors qu'un cache d’instruction fonctionne parfaitement. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Compléments sur les mémoires de masse | prevText=Compléments sur les mémoires de masse | next=Le préchargement | nextText=Le préchargement }} </noinclude> pcp1on3mmrjzpyh0hkc9za01b69mwx6 765115 765114 2026-04-26T14:46:13Z Mewtow 31375 /* L'exemple des processeurs 386 et du contrôleur de cache 82385 */ 765115 wikitext text/x-wiki Le cache est une mémoire intercalée entre la mémoire et un processeur, plus rarement à l'intérieur d'un périphérique. Il est souvent fabriquée avec de la mémoire SRAM, parfois avec de l'eDRAM. Sans lui, on se croirait à l'âge de pierre tellement nos PC seraient lents ! En effet, la mémoire est très lente comparée au processeur. Le temps mis pour accéder à la mémoire est du temps durant lequel le processeur n'exécute pas d'instruction (sauf cas particuliers impliquant un pipeline). Pour diminuer ce temps d'attente, il a été décidé d'intercaler une mémoire petite mais rapide, entre le processeur et la mémoire. Ainsi, le processeur accède à un cache très rapide plutôt qu'à une RAM beaucoup plus lente. ==L'accès au cache== Le cache contient une copie de certaines données présentes en RAM. La copie présente dans le cache est accessible bien plus rapidement que celle en RAM, vu que le cache est plus rapide. Mais seule une petite partie de ces données sont copiées dans le cache, les autres données devant être lues ou écrites dans la RAM. Toujours est-il que le cache contient une copie des dernières données accédées par le processeur. Une donnée est copiée dans la mémoire cache quand elle est lue ou écrite par le processeur. Le processeur conserve une copie de la donnée dans le cache après son premier accès. Les lectures/écritures suivantes se feront alors directement dans le cache. Évidemment, au fur et à mesure des accès, certaines données anciennes sont éliminées du cache pour faire de la place aux nouveaux entrants, comme nous le verrons plus tard. [[File:Principe d'une mémoire cache.gif|centre|vignette|upright=2|Principe d'une mémoire cache.]] La mémoire cache est invisible pour le programmeur, qui ne peut pas déceler celles-ci dans l'assembleur. Les accès mémoire se font de la même manière avec ou sans le cache. La raison à cela est que le cache intercepte les accès mémoire et y répond s'il en a la capacité. Par exemple, si le cache intercepte une lecture à une adresse et que le contenu de cette adresse est dans le cache, le cache va outrepasser la mémoire RAM et la donnée sera envoyée par le cache au lieu d'être lue en RAM. par contre, si un accès se fait à une adresse pour laquelle le cache n'a pas la donnée, alors l'accès mémoire sera effectué par la RAM de la même manière que si le cache n'était pas là. [[File:Accès au cache.png|centre|vignette|upright=2|Accès au cache]] ===Les succès et défauts de caches=== Tout accès mémoire est intercepté par le cache, qui vérifie si la donnée demandée est présente ou non dans le cache. Si la donnée voulue est présente dans le cache, on a un '''succès de cache''' (''cache hit'') et on accède à la donnée depuis le cache. Sinon, c'est un '''défaut de cache''' (''cache miss'') et on est obligé d’accéder à la RAM. Les défauts de cache peuvent avoir plusieurs origines. Tout ce qu'il faut savoir est que lorsque le processeur accède à une donnée ou une instruction pour la première fois, il la place dans la mémoire cache car elle a de bonnes chances d'être réutilisée prochainement. La raison à cela est qu'un programme a tendance à réutiliser les instructions et données qui ont été accédées dans le passé : c'est le ''principe de localité temporelle''. Bien évidement, cela dépend du programme, de la façon dont celui-ci est programmé et accède à ses données et du traitement qu'il fait, mais c'est souvent vrai en général. La première cause des défauts de cache est liée à la taille du cache. À force de charger des données/instructions dans le cache, le cache fini par être trop petit pour conserver les anciennes données. Le cache doit bien finir par faire de la place en supprimant les anciennes données, qui ont peu de chances d'être réutilisées. Ces anciennes données éliminées du cache peuvent cependant être accédées plus tard. Tout prochain accès à cette donnée mènera à un cache miss. C'est ce qu'on appelle un ''Capacity Cache Miss'', ou encore '''défaut de capacité'''. Les seules solutions pour éviter cela consistent à augmenter la taille du cache ou à optimiser le programme exécuté (voir plus bas). Une autre raison pour un défaut est donc la suivante. Lorsqu'on exécute à une instruction ou qu'on accède à donnée pour la première fois, celle-ci n'a pas encore été chargée dans le cache. Le défaut de cache est inévitable : ce genre de cache miss s'appelle un ''Cold Miss'', ou encore un '''défaut à froid'''. De tels défauts sont presque impossibles à éliminer, sauf à utiliser des techniques de préchargement qui chargent à l'avance des données potentiellement utiles. Ces méthodes de préchargement se basent sur le principe de localité spatiale, à savoir le fait que les programmes ont tendance à accéder à des données proches en mémoire. Pour donner un exemple, les instructions d'un programme sont placées en mémoire dans l’ordre dans lequel on les exécute : la prochaine instruction à exécuter est souvent placée juste après l'instruction en cours (sauf avec les branchements). Quand on accède à une donnée ou une instruction, le cache peut précharger les données adjacentes pour en profiter. Nous parlerons de ces techniques de préchargement dans un chapitre dédié, vers la fin du cours. ===Le fonctionnement du cache, vu du processeur=== Vu du processeur, le cache prend en entrée toutes les informations nécessaires pour effectuer un accès mémoire : des signaux de commande, une adresse et la donnée à écrire si besoin. Tout cela est passé en entrée du cache, celui-ci répondant aux accès mémoire via divers bits de contrôles, que le processeur peut lire à souhait. Le cache fournit aussi la donnée à lire, pour les lectures, sur une sortie, connectée directement au bus mémoire/processeur. Globalement, le cache a une capacité limitée, mais il prend en entrée des adresses complètes. Par exemple, sur un processeur 64 bits, le cache prend en entrée des adresses de 64 bits (sauf si optimisations), même si le cache en question ne fait que quelques mébioctets. Les caches sont souvent des mémoires multiports, surtout sur les processeurs récents. Les caches simple port sont rares, mêmes s'ils existent et ont existé par le passé. les caches double port sont eux plus fréquents, et ont généralement un port d'écriture séparé du port de lecture. Mais les caches récents ont plusieurs ports de lecture/écriture et sont capables de gérer plusieurs accès mémoire simultanés. Les données présentes dans le cache sont (pré)chargées depuis la mémoire, ce qui fait que toute donnée dans le cache est la copie d'une donnée en mémoire RAM. Le cache doit faire la correspondance entre une donnée du cache et l'adresse mémoire correspondante. Du point de vue du fonctionnement, on peut voir le cache comme une sorte de table de correspondance, qui mémorise des données, chacune étant associée à son adresse mémoire. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Cela vaut du point de vue du processeur, le fonctionnement interne du cache étant quelque peu différent selon le cache. Il existe des caches dont le fonctionnement interne est bien celui d'une table de correspondance matérielle, d'autres qui sont beaucoup plus optimisés. [[File:Fonctionnement d'une mémoire associative à correspondance.png|centre|vignette|upright=2|Fonctionnement simplifié d'une mémoire cache : les adresses sont dans la colonne de gauche, les données sont dans la colonne de droite. On voit qu'on envoie l'adresse au cache, que celui-ci répond en renvoyant la donnée associée.]] ==La performance des mémoires caches== L'analyse de la performance des mémoires caches est plus riche pour celle des autres mémoires. Sa performance dépend de beaucoup de paramètres, mais on peut cependant citer les principaux. Les deux premiers sont tout bonnement sa latence et son débit, comme pour n'importe quelle autre mémoire. La latence est plus importante que son débit, car le processeur est généralement plus rapide que le cache et qu'il n'aime pas attendre. Mais le critère le plus important pour un cache est sa capacité à empêcher des accès mémoire, son efficacité. Plus les accès mémoire sont servis par le cache au lieu de la RAM, meilleures seront les performances. Pour résumer, la performance d'un cache est surtout caractérisée par deux métriques : le taux de défaut, qui correspond à l’efficacité du cache, et la latence du cache. ===Le taux de succès/défaut=== Le '''taux de succès''' (hit ratio) est un premier indicateur des performances du cache, mais un indicateur assez imparfait. C'est le pourcentage d'accès mémoire qui ne déclenchent pas de défaut de cache. Plus il est élevé, plus le processeur accède au cache à la place de la RAM et plus le cache est efficace. Certains chercheurs préfèrent utiliser le '''taux de défauts''', à savoir le pourcentage d'accès mémoire qui entraînent un défaut de cache. Plus il est bas, meilleures sont les performances. Le taux de défaut est relié au taux de succès par l'équation <math>T_\text{succes} = 1 - T_\text{defaut}</math>. Par définition, il est égal à : : <math>\text{Taux de défauts de cache} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d’accès mémoires}}</math> Plutôt que de comparer le nombre de défauts/succès de cache au nombre d'accès mémoire, il est aussi possible de diviser le nombre de défauts par le nombre total d'instructions. On obtient alors le '''taux de défauts/succès par instruction''', une autre métrique utile. Par définition, elle est égale à : : <math>\text{Taux de défauts par instruction} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d'instructions}} = \text{Taux de défauts de cache} \times \frac{\text{Nombre d’accès mémoires}}{\text{Nombre d'instructions}}</math> Si certains défauts de cache sont inévitables quel que soit le cache, comme les défauts à froids, mentionnés plus haut, d'autres défauts peuvent être évités en augmentant la capacité du cache. C'est le cas des défauts de capacité qui sont causés par un accès à une donnée qui a été éliminée du cache faute de place. Plus le cache est gros, moins il a de chances d'être rempli, moins il doit rapatrier de données, plus son taux de succès augmente. Mais nous reviendrons sur le lien entre taille du cache et taux de défaut plus bas. Le taux de succès ne dépend pas que du cache, mais aussi de la conception des programmes exécutés. Une bonne utilisation du cache (ainsi que de la mémoire virtuelle) repose sur le programmeur qui doit prendre en compte les principes de localités dès la conception de ses programmes. Par exemple, un programmeur peut parfaitement tenir compte du cache au niveau de son algorithme : on peut citer l'existence des algorithmes ''cache oblivious'', qui sont conçus pour être optimaux quelle que soit la taille du cache. Le programmeur peut aussi choisir ses structures de données de manière à améliorer la localité. Par exemple, un tableau est une structure de donnée respectant le principe de localité spatiale, tandis qu'une liste chaînée ou un arbre n'en sont pas (bien qu'on puisse les implémenter de façon à limiter la casse). D'autres optimisations sont parfois possibles : par exemple, le sens de parcours d'un tableau multidimensionnel peut faire une grosse différence. Cela permet des gains très intéressants pouvant se mesurer avec des nombres à deux ou trois chiffres. Je vous recommande, si vous êtes programmeur, de vous renseigner le plus possible sur les optimisations de code ou algorithmiques qui concernent le cache : il vous suffira de chercher sur Google. Il y a une citation qui résume bien cela, prononcée par un certain Terje Mathisen. Si vous ne le connaissez pas, cet homme est un vieux programmeur (du temps durant lequel on codait encore en assembleur), grand gourou de l’optimisation, qui a notamment travaillé sur le moteur de Quake 3 Arena. {{BlocCitation|Almost all programming can be viewed as an exercise in caching.|auteur=Terje Mathisen}} ===La latence moyenne d'un cache=== Le temps mis pour lire ou écrire une donnée varie en présence d'un cache. Certaines lectures/écritures vont atterrir directement dans le cache (succès) tandis que d'autres devront aller chercher leur contenu en mémoire RAM (défaut de cache). Dans tous les cas, qu'il y ait défaut ou non, le cache sera consulté et mettra un certain temps à répondre, égal au temps de latence du cache. Tous les accès mémoires auront donc une durée au moins égale au temps de latence du cache, qui sera notée <math>T_c</math>. En cas de succès, le cache aura effectué la lecture ou l'écriture, et aucune action supplémentaire n'est requise. Ce qui n'est pas le cas en cas de défaut : le processeur devra aller lire/écrire la donnée en RAM, ce qui prend un temps supplémentaire égal au temps de latence de la mémoire RAM. Un défaut ajoute donc un temps, une pénalité, à l'accès mémoire. Dans ce qui suivra, le temps d'accès à la RAM sera noté <math>T_m</math>. Fort de ces informations, nous pouvons calculer le temps de latence moyen d'un accès mémoire, qui est la somme du temps d'accès au cache (pour tous les accès mémoire), multiplié par le temps lié aux défauts. On a alors : : <math>T = T_c + \text{Taux de défaut} \times T_m</math> On voit que plus le taux de succès est élevé, plus le temps de latence moyen sera bas, et inversement. Ce qui explique l'influence du taux de succès sur les performances du cache, influence assez importante sur les processeurs actuels. De nos jours, le temps que passe le processeur dans les défauts de cache devient de plus en plus un problème au fil du temps, et gérer correctement le cache est une nécessité, particulièrement sur les processeurs multi-cœurs. Il faut dire que la différence de vitesse entre processeur et mémoire est tellement importante que les défauts de cache sont très lents : alors qu'un succès de cache va prendre entre 1 et 5 cycles d'horloge, un cache miss fera plus dans les 400-1000 cycles d'horloge. Tout ce temps sera du temps de perdu que le processeur aura du mal à mitiger. Autant dire que réduire les défauts de cache est beaucoup plus efficace que d'optimiser les calculs effectués par le processeur (erreur courante chez de nombreux programmeurs, notamment débutants). ===L'impact de la taille du cache sur le taux de défaut et la latence=== Il y a un lien entre taille du cache, taux de défaut, débit binaire et latence moyenne. Globalement, plus un cache est gros, plus il est lent. Simple application de la notion de hiérarchie mémoire vue il y a quelques chapitres. Les raisons à cela sont nombreuses, mais nous ne pouvons pas les aborder ici, car il faudrait que nous sachions comment fonctionne un cache et ce qu'il y a à l'intérieur, ce qui sera vu dans la suite du chapitre. Toujours est-il que la latence moyenne d'un cache assez gros est assez importante. De même, le débit binaire d'un cache diminue avec sa taille, mais dans une moindre mesure. Les petits caches ont donc un gros débit binaire et une faible latence, alors que c'est l'inverse pour les gros caches. Une grande capacité de cache améliore le taux de succès, mais cela se fait au détriment de son temps de latence et de son débit, ce qui fait qu'il y a un compromis assez difficile à trouver entre taille du cache, latence et débit. Il peut arriver qu'augmenter la taille du cache augmente son temps d'accès au point d’entraîner une baisse de performance. Par exemple, les processeurs Nehalem d'Intel ont vus leurs performances dans certains jeux vidéos baisser de 2 à 3 %, malgré de nombreuses améliorations architecturales, parce que la latence du cache L1 avait augmentée de 2 cycles d'horloge. Pour avoir une petite idée du compromis à faire, regardons la relation entre taille du cache et taux de défaut. Il existe une relation approximative entre ces deux variables, appelée la '''loi de puissance des défauts de cache'''. Elle donne le nombre total de défaut de cache en fonction de la taille du cache et de deux autres paramètres. Voici cette loi : : <math>\text{Taux de défauts de cache} \approx K \times \text{Taille du cache}^{- \alpha }</math>, avec <math>K</math> et <math>\alpha</math> deux coefficients qui dépendent du programme exécuté. Le coefficient <math>\alpha</math> est généralement compris entre 0.3 et 0.7, guère plus, et varie suivant le programme exécuté. Précisons que cette loi ne marche que si le cache est assez petit par rapport aux données à utiliser. Pour un cache assez gros et des données très petites, la relation précédente est mise en défaut. Pour s'en rendre compte, il suffit d'étudier le cas extrême où toutes les données nécessaires tiennent dans le cache. Dans ce cas, il n'y a qu'un nombre fixe de défauts de cache : autant qu'il faut charger de données dans le cache. Le nombre de défauts de cache observé dans cette situation n'est autre que le coefficient <math>K</math> de la situation précédente, mais il n'y a aucune dépendance entre taux de défaut et taille du cache. L'origine de cette relation s'explique quand on regarde combien de fois chaque donnée est réutilisée lors de l’exécution d'un programme. La plupart des données finissent par être ré-accédées à un moment ou un autre et il se passe un certain temps entre deux accès à une même donnée. Sur la plupart des programmes, les observations montrent que beaucoup de réutilisations de données se font après un temps très court et qu'inversement, peu de ré-accès se font après un temps inter-accès long. Si on compte le nombre de réutilisation qui ont un temps inter-accès bien précis, on retrouve une loi de puissance identique à celle vue précédemment : : <math>\text{Nombre de réaccès avec un temps inter-accès égal à t} \approx K \times t^{- \beta}</math>, avec t le temps moyen entre deux réutilisations. Le coefficient <math>\beta</math> est ici compris entre 1.7 et 1.3. De manière générale, les coefficients <math>\alpha</math> et <math>\beta</math> sont reliés par la relation <math>\alpha = 1 - \beta</math>, ce qui montre qu'il y a un lien entre les deux relations. Précisons cependant que la loi de puissance précédente ne vaut pas pour tous les programmes informatiques, mais seulement pour la plupart d’entre eux. Il n'est pas rare de trouver quelques programmes pour lesquels les accès aux données sont relativement prédictibles et où une bonne optimisation du code fait que la loi de puissance précédente n'est pas valide. La loi de puissance des défauts de cache peut se démontrer à partir de la relation précédente, sous certaines hypothèses. Si un suppose que le cache est assez petit par rapport aux données, alors les deux relations sont équivalentes. L'idée qui se cache derrière la démonstration est que si le temps entre deux accès à une donnée est trop long, alors la donnée accédée aura plus de chance d'être rapatriée en RAM, ce qui cause un défaut de cache. La chance de rapatriement dépend de la taille du cache, un cache plus gros peut conserver plus de données et a donc un temps avant rapatriement plus long. ==Les lignes de cache et leurs tags== Du point de vue du processeur, les lectures et écritures se font mot mémoire par mot mémoire. Un processeur avec des entiers de 64 bits recoit des données de 64 bits de la part du cache, et y écrit des mots de 64 bits. Mais quand on regarde comment sont stockées les données à l'intérieur du cache, les choses sont différentes. ===Les lignes de cache=== Les données sont mémorisées dans le cache par blocs de plusieurs bytes, d'environ 64 à 256 octets chacun, qui portent le nom de '''lignes de cache'''. Les lignes de cache sont l'unité de stockage que l'on trouve à l'intérieur du cache, mais elles servent aussi d'unité de transaction avec la mémoire RAM. Sur les caches actuels, on transfère les données entre le cache et la RAM ligne de cache par ligne de cache, dans la limite de la taille du bus mémoire. Mais d'autres caches plus anciens permettaient de faire des transferts plus fins. C’est-à-dire qu'on pouvait mettre à jour quelques octets dans une ligne de cache sans avoir à la recopier intégralement depuis ou dans la mémoire RAM. En théorie, on pourrait imaginer des caches où les données sont stockées différemment, où l'unité serait le mot mémoire, par exemple. Par exemple, sur un processeur 64 bits, on aurait une ligne de cache de 64 bits. Cela aurait l'avantage de la simplicité : les transferts entre le processeur et la mémoire serait de même taille, l'intérieur du cache ressemblerait à son interface montrée au processeur. Mais cela aurait quelques défauts qui sont compensés par l'organisation en lignes de cache de grande taille. Le premier avantage des lignes de cache est lié à la localité spatiale, la tendance qu'on les programmes à accéder à des données proches les unes des autres. Des accès mémoires consécutifs ont tendance à se faire à des adresses proches, qui ont de bonnes chances d'être dans la même ligne de cache. Et des accès consécutifs à une même ligne de cache sont plus rapides que des accès à deux lignes distinctes. Une autre raison est tout simplement que cela simplifie considérablement la circuiterie du cache. Pour une capacité identique, il vaut mieux avoir peu de lignes de cache assez grosses, que beaucoup de petites lignes de cache. La raison est que les circuits du cache, comme le décodeur, l'encodeur et autres, ont moins de sorties et sont donc plus simples. ===L'alignement des lignes de cache=== Les lignes de cache sont des blocs de plusieurs dizaines à centaines de bytes, dont la taille est presque toujours une puissance de deux. De plus, les lignes de cache sont alignées en mémoire. Nous avions déjà abordé la notion d'alignement mémoire dans un chapitre précédent, mais le concept d'alignement des lignes de cache est quelque peu différent. Quand nous avions parlé d'alignement auparavant, il s'agissait de l'alignement des données manipulées par le processeur, qui faisait partie du jeu d'instruction du processeur. Ici, nous parlons d'un alignement totalement différent, invisible pour le programmeur, sans lien avec le jeu d’instruction. Voyons de quoi il retourne. Concrètement, cela veut dire que du point de vue du cache, la RAM est découpée en blocs qui font la même taille qu'une ligne de cache, aux positions prédéterminées, sans recouvrement entre les blocs. Par exemple, pour un cache dont les lignes de cache font 256 octets, le premier bloc est à l'adresse 0, le second est 256 octets plus loin, c'est à dire à l'adresse 256, le troisième à l'adresse 512, la quatrième à l'adresse 768, etc. Une ligne de cache de 256 octets contiendra une donnée provenant d'un bloc de RAM de 256 octets, dont l'adresse est systématiquement un multiple de 256. Il n'est pas possible qu'une ligne de cache contienne un bloc de 256 octets dont l'adresse du premier octet serait l'adresse 64, ou l'adresse 32, par exemple. En clair, les adresses de ces blocs sont des multiples de la taille de la ligne de cache, de la taille des blocs. Cela rappelle les contraintes d'alignement vues dans le chapitre "Le modèle mémoire : alignement et boutisme", mais appliquées aux lignes de cache. L'alignement des lignes de cache a des conséquences pratiques pour la conception des caches. Notons qu'il est en théorie possible d'avoir des caches dont les lignes de cache ne sont pas alignées, mais cela poserait des problèmes majeurs. Il serait en effet possible qu'une donnée soit présente dans deux lignes de cache à la fois. Par exemple, prenons le cas où une ligne de cache de 256 commence à l'adresse 64 et une autre ligne de cache commence à l'adresse 0. L'adresse 128 serait dans les deux lignes de cache ! Et cela poserait des problèmes lors des lectures, mais encore plus lors des écritures. C'est pour éviter ce genre de problèmes que les lignes de cache sont alignées avec la mémoire RAM dans tous les caches existants. L'alignement des lignes de cache est une chose que les programmeurs doivent parfois prendre en compte quand ils écrivent du code ultra-optimisé, destiné à des programmes demandant des performances extrêmes. Il arrive que les contraintes d'alignement posent des problèmes. Nous avions vu dans le chapitre sur le boutisme et l'alignement qu'il valait mieux gérer l'alignement des variables des structures de données, pour éviter les accès non-alignés avec le bus mémoire. La même chose est possible, mais pour l'alignement avec des lignes de cache. Typiquement, l'idéal est que, pour une structure de donnée, on puisse en mettre un nombre entier dans une ligne de cache. Ou alors, si la structure est vraiment grande, que celle-ci occupe un nombre entier de lignes de cache. Si ce n'est pas le cas, il y a un risque d'accès non-alignés, c'est à dire qu'une structure se retrouve à cheval sur deux lignes de cache, avec les défauts que cela implique. ===Le tag d'une ligne de cache=== Plus haut, nous avions dit que le cache mémorise, pour chaque ligne de cache, l'adresse RAM associée. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Mais du fait de l'organisation du cache en lignes de cache de grande taille, qui sont de plus alignées en mémoire, il faut nuancer cette affirmation. Le cache ne mémorise pas la totalité de l'adresse, ce qui serait inutile. L'alignement des lignes de cache en RAM fait que les bits de poids faible de l'adresse ne sont pas à prendre en compte pour l'association adresse-ligne de cache. Dans ces conditions, on mémorise seulement la partie utile de l'adresse mémoire correspondante, qui forme ce qu'on appelle le '''tag'''. Le reste de l'adresse indique quelle est la position de la donnée dans la ligne de cache. Par exemple, prenons le cas où le processeur gère des nombres entiers de 64 bits (8 octets) et des lignes de cache de 128 octets : chaque ligne de cache contient donc 16 entiers. Si le processeur veut lire ou écrire un entier bien précis, il doit préciser sa place dans la ligne de cache. Et ce sont les bits de l'adresse mémoire non-inclus dans le cache qui permettent de faire ça. En clair, une adresse mémoire à lire/écrire est interprété par le cache comme la concaténation d'un tag et de la position de la donnée dans la ligne de cache correspondante. [[File:Adressage d'un cache totalement associatif.png|centre|vignette|upright=2|Adressage d'un cache totalement associatif]] Le cache est donc une grande table de correspondance entre tags et lignes de cache. Lors d'un accès mémoire, le cache extrait le tag de l'adresse à lire ou écrire, et le compare avec les tags de chaque ligne de cache. Si une ligne contient ce tag, alors c'est que cette ligne correspond à l'adresse, et c'est un défaut de cache sinon. Lors d'un succès de cache, la ligne de cache est lue depuis le cache et envoyée à un multiplexeur qui sélectionne la donnée à lire dans la ligne de cache. Le fonctionnement est similaire pour une écriture : la donnée à écrire passe dans un démultiplexeur, qui envoie la donnée au bon endroit dans la ligne de cache sélectionnée. [[File:Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.png|centre|vignette|upright=2|Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.]] ===Le contenu d'une ligne de cache=== Dans ce qui va suivre, nous allons considérer que chaque ligne de cache mémorise son tag, les données de la ligne de cache proprement dit, et quelques bits de contrôle annexes qui varient suivant le cache considéré. [[File:Tag d'une ligne de cache.png|centre|vignette|upright=2|Tag d'une ligne de cache.]] Les caches modernes incluent de nombreux bits de contrôle, mais deux d'entre eux sont communs à presque tous les caches modernes : le bit ''Dirty'' et le bit ''Valid''. Le '''bit ''Valid''''' indique si la ligne de cache contient des données valides ou non. Si le bit ''Valid'' est à 0, la ligne de cache est en état valide, à savoir qu'elle contient des données et n'est pas vide. Par contre, si ce bit est à 1, la ligne de cache est invalide et son contenu ne peut pas être lu ou écrit. L'utilité de ce bit est qu'il permet d'effacer une ligne de cache très rapidement : il suffit de mettre ce bit à 0. Il existe des situations où le cache doit être effacé, on dit alors qu'il est invalidé. Une section de ce chapitre sera dédié à l'invalidation du cache. Le '''bit ''Dirty''''' indique qu'une ligne de cache a été modifiée. Par modifiée, on veut dire que le processeur a écrit dedans, qu'il a modifié la ligne de cache. Mais attention : si la donnée a été modifiée dans le cache, la modification n'est pas forcément propagée en mémoire RAM. Le bit ''dirty'' indique si c'est le cas, si l'écriture a été propagée en mémoire RAM. Il précise que la ligne de cache contient des données modifiées, alors que la RAM a des données initiales non-modifiées. Une ligne de cache avec un bit ''dirty'' à 1 est dite ''dirty'', par métonymie. Nous verrons cela en détail dans la section sur les caches ''write-back'' et ''write-through''. Les caches modernes ajoutent des '''bits de détection/correction d'erreur''' dans les bits de contrôle. Pour rappel, les codes de détection/correction d'erreur permettent de se prémunir contre des erreurs matérielles, qui corrompent les données stockées dans une mémoire, ici une mémoire cache. Ils ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Nous reviendrons dessus dans une section ultérieur de ce chapitre. Sur certains caches assez anciens, on pouvait transférer les lignes de caches morceaux par morceaux. Ces caches avaient des lignes de cache divisées en sous-secteurs, ces sous-secteurs étant des morceaux de ligne de cache qu'on pouvait charger indépendamment les uns des autres (mais qui sont consécutifs en RAM). Chaque secteur avait ses propres bits de contrôle, mais le tag était commun à tous les secteurs. [[File:Cache à secteurs.png|centre|vignette|upright=2.5|Cache à secteurs.]] : Dans ce qui va suivre, le terme "ligne de cache" désignera soit un bloc de données copiées depuis la RAM d'une taille de 64/128/256/... octets, soit la concaténation de ces données avec le tag et des bits de contrôle. Les deux définitions ne sont pas équivalentes, mais l'usage a entériné cet abus de langage. Et il faut avouer que cela rend les explications du chapitre plus simples. ==Les instructions de contrôle du cache== Plus haut, nous avions dit que le cache est totalement transparent du point de vue du programmeur. Le cache contient des copies de données en RAM, le programmeur n'a rien à faire pour utiliser le cache correctement. Mais la réalité est que pour des raisons diverses, des processeurs incorporent des '''instructions de contrôle du cache'''. Il s'agit d’instructions qui agissent sur le contenu du cache. Elles existent pour des raisons diverses qu'on détaillera plus bas, mais il s'agit globalement d'une question de performances ou de nécessité pour le système d'exploitation. ===Les instructions de préchargement=== La première instruction de contrôle du cache est une '''instruction de préchargement''', qui demande à charger un bloc de données dans le cache. Elle prend en opérande une adresse mémoire, et le contenu de cette adresse est chargé dans une ligne de cache. Bien sûr, des contraintes d'alignement sont à prendre en compte : on charge un bloc de la même taille qu'une ligne de cache, aligné en mémoire sur la taille du bloc, qui contient l'adresse. L'instruction de préchargement n'est utile que si l'instruction est exécutée bien avant que la donnée ne soit utilisée/lue/écrite. Cela permet de charger une donnée dans le cache à l'avance, d'où le nom de préchargement donné à cette technique. Mais les processeurs modernes gérent des techniques de préchargement automatique, qui ne requièrent pas d'instructions de préchargement. Le préchargement automatique et les instructions de préchargement sont deux solutions complémentaires, mais qui peuvent se marcher sur les pieds. Nous en reparlerons dans le prochain chapitre, qui sera dédié au préchargement automatique. Il faut noter que les instructions de préchargement peuvent être ignorées par le processeur. Sous certaines conditions, le processeur peut décider que l'instruction de préchargement ne sera pas exécutée. Par exemple, il ne va pas précharger une donnée déjà présente dans le cache. Ou encore, si le bus mémoire est occupé, il ne va pas exécuter le préchargement, par manque de ressources matérielles. ===Les instructions d'invalidation et de ''flush''=== Les instructions ''flush'' regroupent deux types d'instructions qui sont souvent utilisées en même temps. Il s'agit des instructions d'invalidation et de nettoyage (''clean''). Les deux termes proviennent de la terminologie ARM, il n'y a pas de terminologie standardisé pour les noms de ces instructions. Dans les grandes lignes, elles permettent de vider le cache, à savoir de rapatrier son contenu en RAM et de réinitialiser le cache à zéro. Elles sont utilisées par le système d'exploitation lors des commutations de contexte, à savoir quand on passe d'un programme à un autre. Elles sont aussi utilisées lors des appels systèmes et routines d'interruption/exception. L'idée est de vider le cache avant d'exécuter un nouveau programme ou une nouvelle routine. Le nouveau programme aura accès à un cache tout propre, les données de l'ancien programme auront été retirée du cache. Les '''instructions ''clean''''' recopient le contenu de la ligne de cache en RAM. Elles forcent la recopie immédiatement de la ligne de cache en mémoire RAM. Pour faire leur travail, elle vérifient si la ligne de cache a été modifiée, avant de la recopier en RAM. Et pour cela, ils vérifient le bit de contrôle ''dirty'', qui est mis à 1 après une première écriture. Si ce bit est à 0, alors pas besoin de recopier la ligne de cache : elle n'a pas été modifiée, la RAM a déjà la bonne copie. Mais s'il est à 1, le cache et la RAM n'ont pas le même contenu, la recopie s'exécute. Les '''instructions d'invalidation''' permettent d'invalider une ligne de cache, à savoir d'effacer son contenu. Nous verrons à quoi servent ces instructions dans la section sur les changement de processus. Invalider une ligne de cache est une opération optimisée : le cache n'est en réalité pas réellement effacé. À la place, le bit ''Valid'' de chaque ligne de cache est juste mis à 0. Il faut noter que l'invalidation efface les lignes de cache sans se préoccuper de leur contenu. Elle se moque qu'une ligne de cache contienne une donnée modifiée, ''dirty'' ou quoique ce soit : la ligne de cache est effacée, point. Il est possible d'invalider une ligne de cache en fournissant une adresse mémoire, mais il est aussi possible d'invalider le cache tout entier. Le choix entre les deux dépend du mode d'adressage de l'instruction d'invalidation. Parfois, il existe une instruction séparée pour invalider tout le cache, et une autre pour invalider une ligne de cache bien précise. Des instructions séparées sont parfois disponibles pour invalider les caches de données et d'instructions, parfois aussi la TLB (un cache qu'on verra dans quelques chapitres). Il est possible de n'invalider que le cache L1, voire le cache L2. Il faut noter que l'invalidation efface tout le cache, mais ne se préoccupe pas de vérifier si les données ont été modifiées dans le cache. Pour certains caches, comme le cache d'instruction, ce n'est pas un problème, vu qu'il est en "lecture seule". Mais pour les caches de données, les données modifiées sont perdues en cas d'invalidation. Heureusement, il existe des instructions d'invalidation qui fusionnent une instruction ''clean'' et une instruction d'invalidation. Il s'agit d''''instructions d'invalidation spéciales'''. ===Les instructions d'optimisation : instructions non-temporelles et écritures optimisées=== Les '''instructions mémoire non-temporelles''' contournent complètement le cache. Par exemple, une lecture peut lire une donnée, mais celle-ci ne sera pas chargée dans le cache, elle passe directement de la RAM vers les registres. Une section entière de ce chapitre sera dédiée au contournement du cache, à savoir aux situations où les accès mémoire doivent passer directement du processeur à la RAM sans passer par le cache. D'autres instructions assez rares incorporent des indications pour le cache. Par exemple, l'instruction ''load last'' des processeurs POWER PC implique que la donnée ne sera utilisée qu'une seule fois. Elle est donc chargée dans le cache, mais la ligne de cache est configurée de manière à être remplacée très rapidement, typiquement avec une valeur de LRU/LFU adéquate. La donnée est bien chargée dans le cache, au cas où elle doive être relue suite à une mauvaise prédiction de branchement ou autre, chose qu'une lecture non-temporelle (qui contourne le cache) ne fait pas. Des indications de ce type sont appelées des '''''cache hint'''''. L''''instruction ''flush''''' permet de préciser qu'une ligne de cache contient une donnée inutile, qui ne sera pas réutilisée par le programme. Pas besoin de la conserver dans le cache, elle peut laisser sa place à des données plus utiles. Or, sans indication, les algorithmes de remplacement d'une ligne de cache risquent de conserver cette donnée trop longtemps, ce qui entraine une certaine pollution du cache par des données inutiles. Une autre instruction est elle beaucoup plus importante : celle de '''pré-allocation sur écriture'''. Elle sert dans le cas où une ligne de cache est complétement écrite. Par exemple, imaginons qu'on veuille écrire dans une portion de mémoire. Si celle-ci n'est pas dans le cache, le processeur va charger une ligne de cache complète depuis la RAM, écrire dans la ligne de cache, puis recopier la ligne de cache modifiée en mémoire RAM. Une écriture en RAM demande donc de faire une lecture et une écriture. Mais les instructions de pré-allocation sur écriture permettent de prévenir qu'une ligne de cache sera intégralement écrite, et qu'il n'y a donc pas besoin de lire celle-ci depuis la RAM. Notons que l'instruction d'écriture qui suit n'est pas une écriture non-temporelle, vu que les données sont écrites dans la ligne de cache, qui est ensuite envoyée en mémoire RAM dès que nécessaire. De plus, les données écrites peuvent ensuite être relue depuis le cache si nécessaire. Enfin, certains processeurs MIPS incorporent une instruction pour modifier le tag d'une ligne de cache. Elles servent à optimiser les copies mémoire, à savoir quand on copie un bloc de données d'un endroit à un autre. L'idée est de charger le bloc de données dans le cache avec une instruction LOAD/PREFETCH, de modifier le tag pour qu'il pointe vers l'adresse à écrire, et de laisser faire le cache pour que l'écriture se fasse en RAM. Mais les contraintes pour utiliser cette instruction sont assez drastiques : les données doivent être alignées sur la taille d'une ligne de cache, le bloc de départ et d'arrivée (l'original versus la copie) ne doivent pas se recouvrir, etc. ==L'associativité des caches et leur adressage implicite== Lorsqu'on souhaite accéder au cache, il faut trouver quelle est la ligne de cache dont le tag correspond à l'adresse demandée. On peut classifier les caches selon leur stratégie de recherche de la ligne correspondante en trois types de caches : totalement associatifs, directement adressés (''direct mapped'') et associatifs par voie. ===Les caches totalement associatifs=== Avec les caches totalement associatifs, toute donnée chargée depuis la mémoire peut être placée dans n'importe quelle ligne de cache, sans aucune restriction. Ces caches ont un taux de succès très élevé, quand on les compare aux autres caches. [[File:Cache totalement associatif.png|centre|vignette|upright=2|Cache totalement associatif.]] Concevoir un cache totalement associatif peut se faire de deux grandes manières différentes. La première consiste tout simplement à combiner une mémoire associative avec une mémoire RAM, en ajoutant éventuellement quelques circuits annexes. La mémoire associative mémorise les tags, alors que la mémoire RAM mémorise les données de la ligne de cache, éventuellement avec quelques bits de contrôle. La ligne de cache est stockée à une adresse A dans la mémoire RAM et son tag est stocké à la même adresse, mais dans la mémoire CAM. Ce faisant, quand on envoie le tag à la mémoire CAM, elle renvoie l'adresse de la ligne de cache dans la mémoire RAM. Cette adresse est alors envoyée directement sur le bus d'adresse de la RAM, et la lecture est effectuée automatiquement. Il faut ajouter quelques circuits annexes pour garantir que les écritures se passent correctement dans les deux mémoires, mais rien de bien terrible. [[File:Cache fabriqué avec une mémoire associative et une RAM.png|centre|vignette|upright=3|Cache fabriqué avec une mémoire associative et une RAM]] Il est cependant possible d'optimiser un tel cache, en fusionnant la mémoire CAM et la mémoire RAM, afin d'éliminer des circuits redondants. Pour comprendre pourquoi, rappelons que les mémoires CAM sont composées d'un plan mémoire, d'un paquet de comparateurs et d'un encodeur. Quant à la mémoire RAM, elle est composée d'un décodeur connecté au plan mémoire. En mettant une CAM suivie d'une RAM, on a un encodeur dont l'entrée est envoyée à un décodeur. [[File:Cache totalement associatif naif.png|centre|vignette|upright=3|Cache totalement associatif naif]] Or, le décodeur réalise l'opération inverse de l'encodeur, ce qui fait que mettre les deux composants à la suite ne sert à rien. On peut donc retirer l'encodeur et le décodeur, et envoyer directement les résultats des comparateurs sur les entrées de commande du plan mémoire de la RAM. [[File:Cache totalement associatif optimisé.png|centre|vignette|upright=2|Cache totalement associatif optimisé]] Avec cette méthode, les circuits du cache ressemblent à ce qui illustré ci-dessous. Le tag est envoyé à chaque ligne de cache. Le tag envoyé est alors comparé avec le Tag contenu dans chaque ligne de cache, comme c'est le cas sur les mémoires associatives. Si une ligne de cache matche avec le tag envoyé en entrée, la ligne pour laquelle il y a eu une égalité est alors connectée sur les lignes de bit (''bitlines''). Cela est réalisé par un circuit commandé par le comparateur de la ligne de cache. Il ne reste plus qu'à sélectionner la portion de la ligne de cache qui nous intéresse, grâce à un paquet de multiplexeurs. Cela permet d'effectuer une lecture ou écriture, mais il faut aussi préciser si il y a eu un défaut de cache ou un succès. Un succès de cache a lieu quand au moins des comparaisons est positive, alors que c'est un défaut de cache sinon. En clair, détecter un succès de cache demande juste de connecter une porte OU à plusieurs entrées à tous les comparateurs. [[File:Organisation générale d'un cache totalement associatif.png|centre|vignette|upright=2|Organisation générale d'un cache totalement associatif.]] ===Les caches directement adressés=== Les caches directement adressés peuvent être vus comme un cache totalement associatif auquel on aurait ajouté des restrictions assez drastiques. Plus haut, on a vu qu'un cache totalement adressé est équivalent à la combinaison d'une CAM avec une RAM. La mémoire CAM prend en entrée un Tag et traduit celui-ci en une adresse qui commande la mémoire RAM interne au cache. Dans ce qui suit, l'adresse interne au cache sera appelé l''''indice''' pour éviter toute confusion. [[File:Cache hash table - 2.png|centre|vignette|upright=2|Fonctionnement interne du cache, expliquée sous forme abstraite, en utilisant la notion d'indice interne au cache.]] Les caches directement adressés cherchent à remplacer la mémoire CAM par un circuit combinatoire. Ce circuit traduit le Tag en indice, mais est beaucoup plus simple qu'une mémoire CAM. Mais qui dit circuit plus simple dit circuit plus limité. Un circuit combinatoire n'est pas aussi versatile que ce qui est permis avec une mémoire CAM. En conséquence, une restriction majeure apparait : toute adresse mémoire est associée dans une ligne de cache prédéfinie, toujours la même. L'association entre ligne de cache et adresse mémoire est faite par le circuit combinatoire, et ne peut pas changer. Les concepteurs de caches s'arrangent pour que des adresses consécutives en mémoire RAM occupent des lignes de cache consécutives, par souci de simplicité. Tout se passe comme suit la mémoire RAM était découpés en blocs de la même taille que le cache. La première adresse du bloc est associée à la première ligne de cache (celle d'indice 0), la seconde adresse est associée à la seconde adresse du_ bloc, et ainsi de suite. Le tout est illustré ci-dessous. [[File:Cache adressé directement.png|centre|vignette|upright=2|Cache adressé directement.]] Avec cette contrainte, le circuit de traduction de l'adresse en adresse mémoire pour la RAM interne au cache est drastiquement simplifié, et disparait même. Une partie de l'adresse mémoire sert à indiquer la position de la donnée dans le cache, le reste de l'adresse sert encode le tag et la position de la donnée dans le ligne de cache. [[File:Cache line.png|centre|vignette|upright=2|Adresse d'une ligne de cache sur un cache adressé directement.]] Un cache directement adressé est conçu avec une RAM, un comparateur, et un paquet de multiplexeurs. En général, la mémoire RAM stocke les lignes de caches complète. Il arrive que l'on utilise deux mémoires RAM : une pour les tags et une pour les données, mais cette technique augmente le nombre de circuits et de portes logiques nécessaires, ce qui réduit la capacité du cache. L'index à lire/écrire est envoyé sur l'entrée d'adresse de la RAM, la RAM réagit en mettant la ligne de cache sur sa sortie de donnée. Sur cette sortie, un comparateur compare le tag de la ligne de cache lue avec le tag de l'adresse à lire ou écrire. On saura alors si on doit faire face à un défaut de cache. Ensuite, un multiplexeur récupère la donnée à lire/écrire. [[File:Direct mapped cache - french.png|centre|vignette|upright=2|Cache directement adressé.]] L'accès à un cache directement adressé a l'avantage d'être très rapide vu qu'il suffit de vérifier une seule ligne de cache : celle prédéfinie. Mais ces caches ne sont cependant pas sans défauts. Vu que le cache est plus petit que la mémoire, certaines adresses mémoires se partagent la même ligne de cache. Si le processeur a besoin d’accéder fréquemment à ces adresses, chaque accès à une adresse supprimera l'autre du cache : tout accès à l'ancienne adresse se soldera par un défaut de cache. Ce genre de défauts de cache causés par le fait que deux adresses mémoires ne peuvent utiliser la même ligne de cache s'appelle un '''défaut par conflit''' (''conflict miss''). Les défauts par conflit n'existent pas sur les caches totalement associatifs. En conséquence, le taux de succès des caches directement adressés est assez faible comparé aux autres caches. [[File:Cache Block Basic Conflict.svg|centre|vignette|upright=1.5|Exemple de ''Conflict Miss''.]] ===Les caches associatifs par voie=== Les caches associatifs par voie sont un compromis entre les caches directement adressés et les caches totalement associatifs. Pour simplifier, ces caches sont composés de plusieurs caches directement adressés accessibles en parallèle, chaque cache/RAM étant appelé une '''voie'''. Avec ces caches, toute adresse mémoire en RAM est associée à une ligne de cache dans chaque voie. [[File:Cache associatif par voie.png|centre|vignette|upright=2|Cache associatif par voie.]] Le schéma ci-dessous compare un cache directement adressé et un cache associatif à deux voies. On voit que chaque adresse est associée à une ligne de cache bien précise avec un cache directement dressé, et à deux lignes de cache avec un cache associatif à deux voies. L'adresse sera associée à 4 lignes de cache sur un cache associatif à 4 voies, à 8 lignes pour un cache à 8 voies, etc. L'ensemble des lignes de cache associées à une adresse est appelé un '''ensemble'''. [[File:Cache Fill.svg|centre|vignette|upright=2|Comparaison entre un cache directement adressé et un cache associatif à deux voies.]] Sur ces caches, toute adresse est découpée en trois parties : un tag, un index, et un décalage, comme sur les caches directement adressés. Comme vous pouvez le voir, l'organisation est identique à celle d'un cache totalement associatif, à part que chaque ensemble tag-ligne de cache est remplacé par une mémoire RAM qui en contient plusieurs. [[File:Implémentation d'un cache associatif par voie.png|centre|vignette|upright=2|Implémentation d'un cache associatif par voie.]] Le risque de conflits d'accès au cache est donc réduit sur un cache associatif à plusieurs voies, et il est d'autant plus réduit que le cache a de voies. Par contre, leur conception interne fait qu'ils ont un temps d'accès légèrement élevé que les caches directement adressés. Les caches associatifs par voie ont donc un taux de succès et un temps d'accès intermédiaire, situé entre les caches directement adressés et totalement associatifs. Ils sont une sorte de compromis entre réduction des défaut par conflits d'accès au cache et temps d'accès, et complexité des circuits. ==Les optimisations des caches associatifs par voie== Les caches partiellement associatifs regroupent les caches associatifs par voie et directement adressés, ainsi que leurs variantes. En clair : tous les caches qui ne sont pas totalement associatifs. Ils peuvent être optimisés de nombreuses manières, que ce soit pour gagner en performance ou pour économiser de l’énergie. Dans cette section, nous allons voir quelles sont ces optimisations. ===Les caches pseudo-associatifs=== Les caches adressés par voie contiennent une mémoire SRAM par voie. En théorie, les voies sont accédées en parallèles, en même temps, afin de voir si l'on a un succès de cache ou un défaut. Les '''caches pseudo-associatifs''' sont identiques aux caches associatifs par voie, si ce n'est qu'ils vérifient chaque voie une par une. Ils ont été utilisés sur des processeurs commerciaux, un exemple étant l'IBM 370. Là encore, on perd en performance pour gagner en consommation d'énergie. Le temps d'accès dans le meilleur des cas est plus faible pour les caches pseudo-associatifs, mais le pire des cas teste tous les caches avant de tomber sur le bon. Les performances sont donc réduites. Mais la consommation énergétique est meilleure, vu qu'on ne vérifie pas forcément toutes les voies en parallèle. On teste la première voie, éventuellement la seconde, peut-être la troisième, etc. Mais dans le cas général, on ne teste qu'une partie des voies, pas toutes, ce qui donne un gain en termes d'énergie. L'implémentation de caches de ce genre demande que l'on parcoure les voies une par une, en commençant de la première jusqu'à la dernière. Pour cela, un simple compteur suffit. Suivant la valeur du compteur, la voie associée est activée puis accédée. Toute la complexité revient à ajouter un circuit qui prend la valeur du compteur, et active la voie associée, lance un accès mémoire dessus. Vu que les voies sont chacune des caches ''direct mapped'', il suffit pour cela de geler les entrées d'adresse, soit en les déconnectant, soit en utilisant du ''clock gating'' ou de l'évaluation gardée. Les détails d'implémentation, non-cités ici, varient selon le cache. ===La prédiction de voie=== Pour réduire le temps d'accès des caches pseudo-associatifs, certains chercheurs ont inventé la '''prédiction de voie''', qui consiste à faire des paris sur la prochaine voie accédée. L'idée est d'accéder à la voie qui contient la donnée voulue du premier coup, en lisant celle-ci en priorité. Dans son implémentation la plus simple, le cache reste un cache pseudo-associatif. Lors d'un accès au cache, les voies sont toutes parcoures une par une. Par contre, les voies ne sont donc pas parcourues de la première vers la dernière, mais dans un ordre différent. Cette technique permet de mettre en veille les voies sur lesquels le processeur n'a pas parié, ce qui permet de diminuer la consommation énergétique du processeur. C'est plus efficace que d'aller lire plusieurs données dans des voies différentes et de n'en garder qu'une. L'implémentation est assez simple : il suffit d'ajouter un circuit de prédiction de voie,relié au compteur de voie. Une amélioration de la technique fait fonctionner le cache comme un intermédiaire entre cache pseudo-associatif et associatif par voies. L'idée est de chercher la voie prédite en premier, puis de chercher dans toutes les voies en parallèle en cas de défaut de cache. Au lieu d'attendre que les comparaisons de tags donnent leur résultat, le processeur sélectionne automatiquement une voie et configure les multiplexeurs à l'avance. Si le processeur ne se trompe pas, le processeur accède à la donnée plus tôt que prévu. S'il se trompe, le processeur annule la lecture effectuée en avance et recommence en faisant un accès en parallèle aux autres voies. Le compromis entre performance et consommation d'énergie est alors différent. On économise de l'énergie par rapport à un cache associatif par voie, au prix d'une petite perte de performance (doublement des temps d'accès). Mais par rapport à un cache pseudo-associatif, l'économie d'énergie est bien moindre, au prix d'un gain en performance assez manifeste. Prédire quelle voie sera la bonne est assez simple. En vertu du principe de localité, les accès futurs ont des chances de tomber dans les voies les plus fréquemment utilisées ou dans celle plus récemment utilisée. Il suffit de retenir la voie la plus récemment accédée dans un registre, qui sera utilisée comme prédiction. Pour vérifier que la prédiction est correcte, il suffit de comparer le registre et le résultat obtenu après vérification des tags. Cependant, on peut complexifier l'implémentation pour prendre en compte l'adresse à lire/écrire, l'instruction à l'origine de l'accès mémoire ou tout autre paramètre utile. Par exemple, des instructions différentes ont tendance à aller chercher leurs données dans des ensembles différents et la voie à choisir n'est pas la même. Pour cela, il suffit d'utiliser un cache pour stocker la correspondance instruction - voie. Pour plus de simplicité, la mémoire cache des prédictions est parfois remplacée par une RAM, qui est adressée : * soit par le program counter de l'instruction à l'origine de l'accès (en réalité, seulement quelques bits de poids faible de l'adresse) ; * soit par l'adresse à accéder (là encore, quelques bits de poids faible) ; * soit (pour les modes d'adressage qui utilisent un registre de base et un décalage) par un XOR entre les bits de poids faible de l'adresse de base et le décalage ; * soit par autre chose. ===La mise en veille sélective des voies=== Les caches associatifs ont tendance à utiliser beaucoup d'énergie, même quand on n'y accède pas. Aussi, certains processeurs détectent quand le cache est peu utilisé et en profitent pour mettre en veille les voies inutilisées. Vous vous demandez certainement ce qui se passe quand une donnée à lire/écrire est dans une voie désactivée. La réponse est que le cache détecte cette situation, car elle déclenche un succès de cache. Les ''tags'' ne sont en effet pas désactivés, seules les données sont mises en veille. L'implémentation est plus simple sur les caches qui séparent les tags et les données dans deux RAM différentes. Cette optimisation marche surtout sur les gros caches, qui ont des chances d'avoir une portion significative d’inutilisée (pas assez de données pour les remplir), donc généralement les caches L3/L4. Par exemple, les processeurs d'Intel de microarchitecture Ivy Bridge disposent d'un cache de 8 mébioctets à 16 voies, qu'ils peuvent faire passer à 512 kibioctets si le besoin s'en fait sentir. Quand ces processeurs détectent une faible activité, ils mettent en veille 14 voies et n'en gardent que 2 d'actives. Évidemment, les 14 voies sont vidées avant d'être mises en veille, afin qu'une aucune donnée ne soit perdue. ===Les caches ''skew-associative''=== Vous aurez remarqué que dans une voie, les lignes sont accédées en adressage direct : les défauts par conflit sont possibles sur un cache associatif par voie. Pour éviter cela, certains chercheurs ont créé des '''caches ''skew associative''''' (ou associatifs à biais). Pour faire simple, les index des lignes de cache subissent un petit traitement avant d'être utilisés. Le traitement en question est différent suivant la voie de destination, histoire que deux adresses mémoires avec des index identiques donnent des index différents après traitement. Le traitement en question est souvent une permutation des bits de l'index, qui est différente suivant la voie prise, ou un simple XOR avec un nombre qui dépend de la voie. [[File:Implémentation d'un cache skew associative.jpg|centre|vignette|upright=2|Implémentation d'un cache skew associative.]] ==Les caches splittés (''phased caches'')== Dans cette section, nous allons voir les '''caches splittés''' (''phased caches''), qui sont une variante des caches ''direct-mapped'', dans lequel le cache est accédé en deux étapes consécutives. Il ne s'agit pas des caches pipelinés, que nous verrons dans le chapitre sur les processeurs pipélinés, mais laissons cela à plus tard. Il est possible d'appliquer la même méthode sur un cache associatif par voie, mais il y a des méthodes plus simples, qui permettent là aussi d’accéder au cache en plusieurs étapes consécutives. L'idée est de scinder le cache en deux : une mémoire pour les tags, une autre pour les données de la ligne de cache. Les bits de contrôle peuvent être mis dans l'une ou l'autre SRAM, mais ils sont souvent mis dans la RAM pour les tags. En faisant cela, quelques optimisations deviennent possibles, afin de réduire la consommation énergétique en contrepartie d'une perte de performance. La technique s'implémente différemment pour les caches totalement associatifs et partiellement associatifs. Les caches totalement associatifs splittés sont ceux formés en combinant un cache associatif avec une CAM et une RAM combinée. On envoie l'adresse à lire/écrire à la mémoire associative, elle répond en envoyant une adresse à la mémoire RAM. L'accès se fait donc en deux temps, avec l'adresse dans la RAM comme intermédiaire. Il est possible de séparer physiquement les deux étapes en insérant un registre entre la CAM et la RAM, ce qui permet aussi de pipeliner l'accès. Mais c'est rarement fait en pratique, car le cout en circuit d'une mémoire CAM est trop important. L'équivalent pour un cache totalement associatif optimisé, sans CAM et RAM séparée, est trop gourmande en interconnexions pour être implémentée. Les caches totalement associatifs splittés sont donc très rares, l'auteur ne connait aucun exemple de processeur avec un tel cache. Il existe une technique équivalente pour les caches ''direct-mapped'', mais elle demande une certaine modification du cache. Dans les caches ''direct-mapped'' non-splittés, on trouve une mémoire SRAM dont chaque mot mémoire contient une ligne de cache entière, tag inclus. Dans leurs versions splittés, la SRAM est séparée en deux : une pour les tags, une autre pour les données. Précisons qu'il s'agit bien de deux mémoires SRAM adressables. L'adresse à laquelle accéder est envoyée à la SRAM des tags, puis ensuite à la SRAM des données si besoin. L'idée est d’accéder aux tags pour déterminer s'il y a un succès de cache ou un défaut, et ensuite d'accéder aux données. On n’accède pas aux données en parallèle des tags. Faire cela est évidemment plus lent. En cas de défaut de cache, le temps d'accès est similaire : le tag ne correspond pas, on n'accède pas à la SRAM pour les données. Par contre, vu qu'on n'a pas activé la SRAM pour les données, on économise un peu d'énergie, ce qui réduit la consommation d'énergie. En cas de succès de cache, on accède à la SRAM pour les tags, puis à celle pour les données. Pas d'économie d'énergie à l'horizon, sans compter que le temps d'accès augmente : on accède au cache en deux étapes au lieu de faire les deux accès en parallèle. [[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]] Précisons cependant que ce design peut avoir deux avantages en termes de performance. Premièrement, le temps d'accès au cache est légèrement amélioré en cas de défaut de cache. En effet, la SRAM des tags est assez petite, idem pour celle des données. Leur temps d'accès est donc plus faible que pour une grosse SRAM contenant données et tags. Le gain en temps d'accès est donc un avantage, qui ne se manifeste surtout en cas de défaut de cache. Un autre avantage est que l'accès au cache se pipeline plus facilement, ce qui fait qu'on peut effectuer plusieurs accès simultanés au cache. Mais nous verrons cela dans quelques chapitres. ===L'exemple des processeurs 386 et du contrôleur de cache 82385=== Il est important de noter que la séparation entre tags et RAM peut être telle que les deux ne sont pas sur la même puce de silicium ! Voire que les deux sont séparés du processeur ! C'était le cas quand les mémoires cache ont été introduites sur les processeurs grand public, notamment sur les premiers processeurs Intel. La miniaturisation n'avait pas avancé au point où placer un cache dans le processeur était possible. Sur le processeur 386 d'Intel, le cache était un cache splitté, séparé du processeur. Concrètement, le processeur i386 était couplé à un contrôleur de cache Intel 82385 contenant les ''tags'' et les bits de contrôle, et une mémoire SRAM pour les lignes de cache. Le 82385 était un composant passif, qui n'était pas un intermédiaire entre processeur et mémoire RAM. Il surveillait ce qui se passait sur le bus de données et répondait à la place de la RAM pour certaines lectures. La conséquence est que c'était un cache ''write through'' : le processeur envoyait les écritures sur le bus mémoire, le cache les voyait passer et agissait en conséquence en cas de succès de cache. Il pouvait commander soit un cache ''direct mapped'', soit associatif à deux voies. La différence entre les deux est que le 82385 était relié à une mémoire SRAM avec un cache ''direct mapped'', deux SRAM pour deux voies. Pour avoir un cache associatif à deux voies, le 82385 devrait gérer deux signaux ''chip select'' pour activer chaque SRAM/voie suivant les besoins. Il avait précisément quatre signaux CS : deux par SRAM, un pour les lectures, un pour les écritures. Notons que les SRAM utilisées devaient être des SRAM avec un bus de données de 32 bits, des SRAM 16 ou 8 bits ne fonctionnaient pas. Le 82385 rajoutait 4 sorties, pour masquer chaque octet dans ces 32 bits. Cette surveillance du bus permettait de gérer une forme limitée de cohérence des caches par invalidation. Dès que le 82385 détectait une prise de contrôle du bus par autre chose que le processeur, il surveillait les adresses transmises sur le bus. En cas de succès de cache, la ligne de cache associée était invalidée. Au-delà de ca, le 82385 avait une entrée FLUSH, qui ordonnait une invalidation totale du cache. Si cette entrée est mise à 1, toutes les lignes de cache sont invalidées. Les ''tags'' sont marqués comme invalides, mais les lignes de cache elles-mêmes ne sont pas touchées. Il gérait aussi les accès mémoire non-cacheable, à savoir des accès mémoire qui ne doivent pas être pris en compte par le cache. Il permettait de programmer des intervalles d'adresse auxquels ne pas répondre. Le cache était mis en pause lors des interruptions, mais aussi lors des accès à des entrée-sorties (le processeur utilisait un espace d'adressage séparé pour les entrées-sorties, le bit IO inhibait l'action du cache). Les accès à des composants 16 bits n'étaient pas mis en cache eux aussi. ===L'exemple des processeurs Intel de microarchitecture ''Broadwell''=== Un autre exemple est celui du cache L4 des processeurs Broadwell et de quelques processeurs séparés. Ces processeurs ont une organisation en ''chiplet'' où le processeur incorpore plusieurs puces séparées : une puce pour le processeur proprement dit, une puce nommée ''Crystal Well'' pour le cache L4, et une puce IO pour la communication avec la RAM et la carte mère. Le processeur incorporait un cache L4 de 128 mébioctets, composé de mémoire eDRAM, qui était dispersé entre ''Crystal Well'' et les autres puces. Les données du cache L4 étaient dans ''Crystal Well'', alors que les Tags étaient soit dans le processeur lui-même, soit dans la puce IO ! La puce ''Crystal Well'' était une mémoire DRAM adressable tout ce qu'il y a de plus basique, avec cependant quelques optimisations notables. Par exemple, elle avait deux bus séparés pour l'écriture et la lecture. De plus, elle avait une organisation interne avec 128 banques, contre moins d'une dizaine pour la DDR de l'époque et environ 32 banques pour la DDR5 moderne. Elle contenait aussi quelques circuits pour gérer son rôle de mémoire cache, mais rien en ce qui concerne la gestion des tags eux-mêmes. Sur les processeurs de microarchitecture ''Broadwell'', les tags étaient placés dans le CPU et précisément dans le cache L3. À chaque accès mémoire au cache L3, les tags du cache L4 étaient consultés en parallèle. De fait, l'accès au cache L4 était assez rapide, malgré le fait que les données étaient dans une puce à part. Ajoutons à cela que le processeur et ''Crystal Well'' n'avaient pas la même finesse de gravure ni la même technologie de fabrication. Les tags étaient implémentés avec de la SRAM contre la DRAM pour les données, ce qui fait que la consultation des tags était plus rapide que l'accès aux données. Par la suite, dans certains CPU de microarchitecture ''skylake'', les tags ont été déplacés en-dehors du processeur pour finir dans le contrôleur mémoire. En faisant cela, le cache L4 pouvait être utilisé par autre chose que le processeur, et notamment par la carte graphique intégrée au CPU. Avec ''broadwell'', le fait que les tags étaient consultés en cas d'accès au L3 empêchait au GPU intégré de consulter le cache L4. Mais en déplaçant les tags dans le contrôleur mémoire, ce n'est plus le cas vu que la carte graphique a aussi accès au bus mémoire. Par contre, le temps d'accès augmente comparé à la solution précédente. On n'accède pas aux tags du L4 en parallèle du L3 : à la place, il faut consulter les tags du L3, détecter un défaut de cache L3, et ensuite accèder aux tags. ===Les caches RAM-configurables=== Un autre avantage des caches splittés est qu'on peut les modifier pour servir à la fois de mémoire cache, mais aussi de ''local store'', de mémoire RAM de petite taille. Le fonctionnement est assez simple à comprendre. Lors d'un accès au cache, on accède aux tags, puis à la RAM interne au cache. Lors d'un accès au ''local store'', on contourne l'accès au tags et on accède à la RAM interne au cache directement. Il s'agit de la technique du '''cache RAM-configurable''. L'usage de cache RAM-configurable est fréquent sur les cartes graphiques récentes, qui incorporent un ou plusieurs processeurs multicoeurs, dont le cache L1 de données est un cache RAM-configurable. [[File:Hydride cache - local store.png|centre|vignette|upright=2.0|Hydride cache - local store]] ===La compression de cache=== Une autre optimisation permise par les ''phased caches'' est l'implémentation de techniques de '''compression de cache''', qui visent à compresser des lignes de cache. L'intérêt est qu'on peut stocker plus de données dans le cache, à capacité égale. L'inconvénient est qu'on doit compresser/décompresser les lignes de cache, ce qui demande un circuit en plus et allonge les temps d'accès. En effet, le temps mis pour compresser/décompresser une ligne de cache s'ajoute au temps d'accès. Aussi, la compression de cache sert surtout pour les caches de bas niveau dans la hiérarchie mémoire, les gros caches aux temps d'accès assez longs. Une première technique, assez simple à implémenter et peu couteuse en circuit, est celle de la '''compression des lignes de cache nulles'''. Elle compresse uniquement les lignes de cache qui ne contiennent que des zéros. L'idée est qu'on ajoute, dans la mémoire des tags, un bit de contrôle pour chaque ligne de cache appelé le bit ''null''. Il indique si la ligne de cache ne contient que des zéros. Quand on lit une ligne de cache, la mémoire des tags est accédée et on vérifie le bit ''null'' : s'il vaut 1, on n'accède pas à la mémoire cache de données et un multiplexeur envoie un zéro sur le port de lecture. Le bit ''null'' est fixé lors de l'écriture d'une ligne de cache : elle passe dans un comparateur avec zéro relié à la mémoire des tags. La comparaison avec zéro peut se faire en parallèle de l'écriture ou avant (dans ce cas, on n'écrit pas la ligne de cache dans le cache). Les autres techniques de compression de cache permettent de compresser autre chose que des lignes de cache nulles. L'idée est qu'une ligne de cache physique peut par moment mémoriser plusieurs lignes de caches compressées. Par exemple, prenons un cache dont les lignes de cache font 64 octets. Il est possible de compresser deux lignes de cache pour qu'elles fassent chacune 32 octets, et les stocker dans une seule ligne de cache. Les deux lignes de cache auront des tags différents, mais pointeront sur la même ligne de cache physique. Et cela demande d'utiliser un ''phased cache'' dont la mémoire pour les tags est plus grande que la mémoire pour les données. Il n'y a donc plus une bijection entre tags et ligne de cache, mais une relation surjective. Chose qui n'est possible qu'avec un ''phased cache''. De plus, des bits de contrôles associés à chaque ''tag'' indiquent où se trouvent les lignes de cache compressées dans la ligne de cache : est-ce que c'est les 32 octets de poids fort ou de poids faible ? [[File:Compression de cache.png|centre|vignette|upright=2|Compression de cache]] Il ne semble pas que les techniques de compression de cache soient implémentées sur les processeurs modernes. Aucun n'utilise de compression de cache, à ma connaissance. Il faut dire que les techniques connues sont de mauvais compromis : le temps d'accès du cache augmente beaucoup, le cout en circuit pourrait être utilisé pour un cache non-compressé mais plus grand. Et notons que la compression de cache ne marche que si les données peuvent se compresser. Si ce n'est pas le cas, une partie de la mémoire des tags est inutilisée. Une revue de la littérature académique sur la compression de cache est disponible via ce lien, pour les curieux : * [https://inria.hal.science/hal-03285041 Understanding Cache Compression, par Carvalho et Seznec]. ==L'adressage physique ou logique des caches== Le cache utilise les adresses à lire/écrire pour déterminer s'il a une copie de la donnée en son sein. Mais l’interaction entre caches et mémoire virtuelle donne lieu à un petit problème : l'adresse utilisée est-elle une adresse virtuelle/logique ou physique ? La réponse varie suivant le processeur : certains caches utilisent l'adresse virtuelle, tandis que d'autres prennent l'adresse physique. On parle de cache '''virtuellement tagué''' dans le premier cas et de cache '''physiquement tagué''' dans le second. {| |[[File:Cache tagué virtuellement.png|vignette|Cache tagué virtuellement.]] |[[File:Cache tagué physiquement.png|vignette|Cache tagué physiquement.]] |} ===L'accès à un cache physiquement/virtuellement tagué=== La manière d'accéder à un cache dépend de s'il est virtuellement ou physiquement tagué. Il faut utiliser l'adresse virtuelle pour les premiers, physique pour les seconds. Avec un cache virtuellement tagué, l'adresse logique peut être envoyée directement au cache. La MMU ne traduit les adresses que s'il faut accéder à la mémoire RAM. Ces caches sont donc plus rapides. Avec un cache physiquement tagué, le processeur doit traduire l'adresse logique en adresse physique dans la MMU, avant d'accéder au cache. La traduction d'adresse se fait soit en accédant à une table des pages en mémoire RAM, soit en accédant à un cache spécifiquement dédié à accélérer la traduction d'adresse, la TLB (''Translation Lookaside Buffer''). Dans la quasi-totalité des cas, la traduction d'adresse passe par la TLB, ce qui fait qu'elle est raisonnablement rapide. Toujours est-il que chaque accès au cache demande d'accéder à la TLB et de faire la traduction d'adresse avant d'accéder au cache. L'accès est donc plus lent que sur les caches virtuellement tagués, où les accès sont plus directs. [[File:Virtual and Physical addressing.svg|centre|vignette|upright=2|Cache tagué virtuellement versus physiquement tagué.]] ===Les défauts des caches virtuellement tagués=== Les caches physiquement tagués sont moins rapides que les caches virtuellement adressés. Pourtant, les caches virtuellement tagués sont peu fréquents sur les processeurs modernes. Et la raison est assez intéressante : c'est une question d'adresses homonymes et synonymes. ====Les droits d'accès doivent être vérifiés lors d'un accès au cache==== Un premier problème est que la protection mémoire est compliquée avec de tels caches. Rappelons que certaines portions de mémoire sont accessibles seulement en lecture, ou sont interdites en écriture, sont inexécutables, etc. Ces droits d'accès sont gérés par la MMU, qui vérifie pour chaque accès mémoire que l'accès est autorisé. En bypassant la MMU, l'accès au cache virtuellement tagué ne permet pas de faire ces vérifications. Il est possible de charger une donnée en lecture seule dans le cache, mais d'y faire des accès en écriture pour les accès ultérieurs. Les solutions à cela sont multiples. La première consiste à consulter la MMU en parallèle de l'accès au cache. L'accès au cache est alors réalisé de manière spéculative, et est ensuite confirmé/annulé une fois que la MMU a rendu son verdict. Les performances du cache restent alors les mêmes : l'accès à la MMU se fait en parallèle de l'accès au cache, pas avant. Une autre solution est d'ajouter les droits d'accès en question dans la ligne de cache, dans les bits de contrôle situés après le Tag. Chaque accès au cache récupère ces bits de contrôle et vérifie si l'accès est autorisé. L'inconvénient est que les lignes de cache deviennent plus longues, les droits d'accès sont dupliqués entre MMU et cache. Mais si le budget en transistor suit, ce n'est rien d'insurmontable. ====Les adresses homonymes perturbent la gestion du cache==== Pour rappel, une adresse logique homonyme correspond à plusieurs adresses physiques différentes. Elles surviennent quand chaque programme a son propre espace d'adressage. Dans ce cas, une adresse logique correspondra à une adresse physique différente par programme.Une autre manière de voir les choses est qu'il y a en réalité deux adresses homonymes, qui ont la même valeur, mais appartiennent à des espaces d'adressage différentes. Et c'est cette seconde interprétation que nous allons utiliser. Les caches doivent gérer ces adresses homonymes et faire en sorte que la lecture/écriture d'une adresse homonyme se fasse à la bonne adresse physique, dans la bonne ligne de cache. Et autant un cache physiquement tagué n'a aucun problème avec ça, vu qu'il ne gère que des adresses physiques, autant des problèmes surviennent avec les caches virtuellement tagués. Le problème est que les caches virtuellement tagués doivent faire la différence entre deux adresses homonymes de même valeur. Pour corriger ces problèmes, il existe deux grandes méthodes. La première méthode est simple : '''vider les caches''' en changeant de programme. Leur contenu est rapatrié en mémoire RAM, puis les caches sont remis à zéro. Le vidage du cache recopie les lignes de cache ''dirty'' (modifiées) en RAM, puis efface/invalide tout le cache. C'est à cela que servent les instructions ''clean'' et d'invalidation vues plus haut, elles ont été inventées pour cette situation précise. Lorsque le système d'exploitation déclenche une commutation de contexte, à savoir qu'il change le programme en cours d'exécution, le processeur vide tous les caches du processeur. Les interruptions font la même chose, elles vide tous les caches du processeur. Une seconde méthode numérote chaque programme en cours d'exécution, chaque processus. Le numéro attribué est spécifique à chaque processus, ce qui fait qu'il est appelé un '''identifiant de processus CPU'''. Le processeur mémorise l'identifiant du programme en cours d'exécution dans un registre dédié. L'identifiant de processus CPU est utilisé lors des accès mémoire. Chaque ligne de cache contient le numéro de l'espace d'adressage associé, dans son ''tag''. Lors de chaque accès mémoire, l'ID du registre est comparé à l'ID de la ligne de cache accédée, pour vérifier que l'accès mémoire accède à la bonne donnée. Cette méthode n'est pas très économe en termes de transistors. L'usage d'identifiant de processus CPU est clairement meilleure en termes de performance, les commutations de contexte sont plus rapides. Par contre, le budget en transistor est plus important. Un autre défaut de cette méthode est que l'identifiant de processus est généralement codé sur une dizaine de bits, alors que le système d'exploitation utilise des identifiants de processus beaucoup plus larges, de 32 à 64 bits sur les CPU 32/64 bits. L'OS doit gérer la correspondance entre identifiants de processus CPU et ceux de l'OS. Parfois, pour cette raison, les OS n'utilisent pas toujours ce système d'identifiant de processus CPU. ====Les adresses synonymes perturbent aussi la gestion du cache==== La gestion des adresses synonymes est aussi un gros problème sur les caches virtuellement tagués. Pour rappel, il s'agit du cas où des adresses logiques différentes pointent vers la même adresse physique. Typiquement, quand deux programmes se partagent un morceau de mémoire, ce morceau correspondra à des adresses synonymes dans les deux espaces d'adressage. Mais il arrive que l'on ait des adresses synonymes dans le même espace d'adressage, ce n'est pas si rare ! Autant les adresses synonymes ne posent aucun problème avec les caches physiquement tagués, ce n'est pas le cas avec les caches virtuellement adressés. Sur ces caches, deux adresses logiques synonymes vont tomber dans deux lignes de cache différentes. Corriger ce problème demande d'ajouter des circuits annexes pour détecter les adresses synonymes, qui sont vraiment complexes et ont un cout en termes de performance. Aussi, les caches virtuellement tagués sont très peu utilisés sur les processeurs modernes. ===Les caches virtuellement adressés, mais physiquement tagués=== Si les caches physiquement et virtuellement tagués ont des défauts, il existe un intermédiaire qui est un bon compromis entre ces deux extrêmes. Il s'agit des '''caches virtuellement adressés - physiquement tagués''', aussi appelés '''caches pseudo-virtuels'''. Pour comprendre comment ils fonctionnent, précisons que ces caches sont soit des caches ''direct-mapped'', soit des caches associatifs par voie (composés de plusieurs RAM ''direct-mapped'' accédées en parallèle, plusieurs voies). L'accès à ce genre de cache se fait en deux temps : on accède à un ou plusieurs RAM ''direct-mapped'' et on vérifie ensuite les ''Tags'' pour sélectionner la bonne voie. Sur les caches ''direct-mapped'', on n'a qu'une seule RAM ''direct-mapped''. Sur les caches associatifs, on a plusieurs RAM ''direct-mapped'', appelées des voies, qui sont accédées en parallèle. L'accès se fait donc en deux étapes : adresser les RAM ''direct-mapped'' avec un indice, vérifier les ''tags'' avec le reste de l'adresse. Une autre chose à rappeler est que l'adresse logique est composée de deux parties : un numéro de page logique qui indique dans quel page se situe l'adresse, un décalage/''offset'' qui indique la position de l'adresse dans la page. La traduction d'adresse transforme le numéro de page logique en numéro de page physique, mais laisse le décalage intouché. L'idée est d'utiliser le décalage pour adresser les RAM avec le décalage, tandis que le numéro de page sert de ''tag''. Le décalage est découpé en deux lors de l'accès au cache : les bits de poids fort forment l'indice (l'adresse envoyée à la voie), les bits de poids faible donnent la position de l'adresse dans la ligne de cache. L'idée est d'utiliser un numéro de page physique pour les ''tags'', mais d'adresser les voies avec le décalage logique. Les deux servent à des instants différents : vérification des ''tags'' pour l'adresse physique, accès aux voies pour l'adresse logique. Ainsi, le problème des adresses synonymes ou homonymes est résolu par l'utilisation de l'adresse physique pour les tags. Par contre, l'accès au cache est plus rapide, car on utilise l'adresse logique pour la première étape. Le processeur accède à la TLB et récupère l'adresse physique pendant que l'on adresse les voies, les deux sont faits en parallèle, ce qui fait que tout se passe comme si l'accès à la TLB était gratuit. La TLB étant assez rapide comparé au cache, l'adresse physique est disponible quand on doit faire la comparaison avec les ''tags''. [[File:Virtual - Physical - Pseudo Virtual addressing.svg|centre|vignette|upright=2|Adressage pseudo virtuel des caches.]] Il s'agit d'un excellent compromis entre performance et correction des problèmes des adresses synonymes/homonymes. Tous les caches des processeurs haute performance utilisent cette méthode, au moins pour leurs caches L1. Les caches L2 tendent à utiliser des caches physiquement adressés, pour lesquels la latence d'accès est suffisante pour qu'on accède à la TLB en amont. La raison est assez simple à expliquer, elle provient d'une contrainte assez précise sur le calcul de l'indice. La conséquence est qu'un cache ''direct-mapped'' ne peut pas dépasser la taille d'une page, soit 4 kibioctets sur les ordinateurs actuels. Sur les caches associatifs, on peut dépasser cette limite en augmentant le nombre de voies, mais la taille maximale d'une voie reste celle d'une page. Cette contrainte n'est pas trop grave sur les caches de petite taille, dont les caches L1. La plupart d'entre eux ont trouvé un compromis idéal avec moins d'une dizaine de voies par cache, chacun de 4 kibioctets, ce qui donne des caches allant de 16 à 64 kibioctets, soit entre 4 et 16 voies. Par contre, un cache de grande taille doit utiliser un grand nombre de voies, ce qui est peu pratique. Aussi, cette technique de caches pseudo-virtuels n'est pas toujours appliquée sur les caches L2, qui sont physiquement adressés. Il faut dire qu'on accède au cache L2 lors d'un défaut dans le cache L1, et l'adresse physique est disponible à ce moment-là, elle a déjà été récupérée lors de l'accès au cache L1. On peut donc l'utiliser pour adresser le cache L2 sans perte de performance. ==Le remplacement des lignes de cache== Lorsqu'un cache est rempli et qu'on charge une nouvelle donnée dedans, il faut faire de la place pour cette dernière. Dans le cas d'un cache directement adressé, il n'y a rien à faire vu que la ligne de cache à évincer est déterminée lors de la conception du cache. Mais pour les autres caches, la donnée peut aller dans n'importe quelle ligne ou voie. Or, le choix des données à rapatrier en RAM doit être le plus judicieux possible : on doit virer de préférence des données inutiles. Rapatrier une donnée qui sera surement utilisée sous peu est inutile, et il vaudrait mieux supprimer des données qui ne serviront plus ou alors dans longtemps. Il existe différents algorithmes spécialement dédiés à résoudre ce problème efficacement, directement câblés dans les unités de gestion du cache. Certains sont vraiment très complexes, aussi je vais vous présenter quelques algorithmes particulièrement simples. Mais avant de voir ces algorithmes, il faut absolument que je vous parle d'une chose très importante. Quel que soit l'algorithme en question, il choisit la ligne de cache à évincer et recopie son contenu dans la RAM. Ce qui demande d'identifier et de sélectionner une ligne de cache parmi toutes les autres. Pour cela, le circuit de remplacement attribue une adresse chaque ligne de cache ! Vous avez bien vu : chaque ligne de cache est numérotée par une adresse, interne au cache. ===Le remplacement aléatoire=== Premier algorithme : la donnée effacée du cache est choisie au hasard ! C'est contre-intuitif, mais cet algorithme donne des résultats assez honorables, en plus d'utiliser très peu de portes logiques (un générateur de nombres pseudo-aléatoire est un circuit assez simple). Généralement, les défauts de cache sont séparés par un nombre assez important et irrégulier de cycles d'horloge. Dans ces conditions, cette technique donne un bon résultat. ===FIFO : first in, first out=== Avec l'algorithme FIFO, la donnée effacée du cache est la plus ancienne, celle chargée dans le cache avant les autres. Cet algorithme est très simple à implémenter en circuit, concevoir une mémoire de type FIFO n'étant pas très compliqué, comme on l’a vu dans le chapitre dédié à ce type de mémoires. Et on peut dire que dans le cas d'un cache, l'implémentation est encore plus simple et se contente d'un seul registre/compteur. Typiquement, il suffit d'ajouter un registre qui mémorise où se situe la donnée la plus récente. Toute insertion d'une nouvelle donnée se fait à l'adresse suivante, ce qui demande juste d'incrémenter le registre avant d'utiliser son contenu pour l'accès mémoire. [[File:Algorithme FIFO de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme FIFO de remplacement des lignes de cache.]] Cet algorithme possède une petite particularité sur les caches associatifs par voie : en augmentant le nombre d'ensembles, les performances peuvent se dégrader : c'est ce qu'on appelle l''''anomalie de Bélády'''. ===MRU : most recently used=== Avec l'algorithme MRU, la donnée remplacée est celle qui a été utilisée le plus récemment. Cet algorithme s'implémente simplement avec un registre, dans lequel on place le numéro de la dernière ligne de cache utilisée. Cet algorithme de remplacement est très utile quand un programme traverse des tableaux du premier élément jusqu'au dernier : les données du tableau sont rarement réutilisées, rendant le cache inutile. Il est prouvé que dans ces conditions, l'algorithme MRU est optimal. Mais dans toutes les autres conditions, cet algorithme a des performances assez misérables. ===LFU : least frequently used=== Avec l'algorithme LFU, la donnée supprimée est celle qui est utilisée le moins fréquemment. Cet algorithme s'implémente en associant un compteur à chaque ligne de cache, qui est incrémenté à chaque accès mémoire. La ligne la moins récemment utilisée est celle dont le compteur associé a la plus petite valeur. Implémenter cet algorithme prend pas mal de transistors, car il faut rajouter autant de compteurs qu'il y a de lignes de cache, en plus d'un circuit pour comparer les compteurs et d'un encodeur. [[File:Algorithme LFU de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme LFU de remplacement des lignes de cache]] ===LRU : least recently used=== Avec l'algorithme LRU, la donnée remplacée est celle qui a été utilisée le moins récemment. Cet algorithme se base sur le principe de localité temporelle, qui stipule qu'une donnée accédée récemment a de fortes chances d'être réutilisée dans un futur proche. Et inversement, la donnée la moins récemment utilisée du cache est celle qui a le plus de chance de ne servir à rien dans le futur. Autant la supprimer en priorité pour faire de la place à des données potentiellement utiles. Implémenter l'algorithme LRU peut se faire de différentes manières, qui ont pour point commun d'enregistrer les accès au cache pour en déduire la ligne la moins récemment accédée. La manière la plus simple demande d'utiliser un compteur pour chaque ligne de mémoire cache, un peu comme le LFU. La différence avec le LFU est que le compteur n'est pas incrémenté lors d'un accès mémoire. À la place, ce compteur est incrémenté régulièrement, chaque incrémentation ayant lieu en même temps pour tous les compteurs. Quand un bloc est chargé dans le cache, ce compteur est mis à zéro. Quand une ligne de cache doit être remplacée, un circuit va vérifier la valeur de tous les compteurs : la ligne LRU (la moins récemment utilisée), est celle dont le compteur a la valeur la plus haute. Le circuit est composé d'un paquet de comparateurs, et d'un encodeur, comme pour l'agorithme LFU. ===Les approximations du LRU=== Implémenter le LRU demande un nombre de transistors proportionnel au carré du nombre de lignes de cache. Autant dire que le LRU devient impraticable sur de gros caches. Ce qui fait que les processeurs modernes implémentent des variantes du LRU, moins couteuses en transistors, qui donnent un résultat approximativement semblable au LRU. En clair, ils ne sélectionnent pas toujours la ligne de cache la moins récemment utilisée, mais une ligne de cache parmi les moins récemment utilisées. Ce n'est pas un problème si grave que cela car les lignes les moins récemment utilisées ont toutes assez peu de chance d'être utilisées dans le futur. Entre choisir de remplacer une ligne qui a 0,5 % de chances d'être utilisée dans le futur et une autre qui a une chance de seulement 1 %, la différence est négligeable en termes de taux de succès. Mais les gains en termes de circuits ou de temps d'accès au cache de ces algorithmes sont très intéressants. L'algorithme le plus simple consiste à couper le cache (ou chaque voie s'il est associatif) en plusieurs sections. L'algorithme détermine la section la moins récemment utilisée, avant de choisir aléatoirement une ligne de cache dans cette section. Pour implémenter cet algorithme, il nous suffit d'un registre qui mémorise le morceau le moins récemment utilisé, et d'un circuit qui choisit aléatoirement une ligne de cache. Cette technique s'adapte particulièrement bien avec des caches associatifs à voies : il suffit d'utiliser autant de morceaux que de voies. Autre algorithme, un peu plus efficace : le '''pseudo-LRU de type M'''. Cet algorithme attribue un bit à chaque ligne de cache, bit qui sert à indiquer de façon approximative si la ligne de cache associée est une candidate pour un remplacement ou non. Il vaut 1 si la ligne n'est pas une candidate pour un remplacement et zéro sinon. Le bit est mis à 1 lorsque la ligne de cache associée est lue ou écrite. Évidemment, au fil du temps, toutes les lignes du cache finiront par avoir leur bit à 1. Lorsque cela arrive, l'algorithme remet tous les bits à zéro, sauf pour la dernière ligne de cache accédée. L'idée derrière cet algorithme est d'encercler la ligne de cache la moins récemment utilisée au fur et à mesure des accès. L'encerclement commence lorsque l'on remet tous les bits associés aux lignes de cache à 0, sauf pour la ligne accédée en dernier. Au fur et à mesure des accès, l'étau se resserre autour de la ligne de cache la moins récemment utilisée. Après un nombre suffisant d'accès, l'algorithme donne une estimation particulièrement fiable. Et comme les remplacements de lignes de cache sont rares comparés aux accès aux lignes, cet algorithme finit par donner une bonne estimation avant qu'on ait besoin d'effectuer un remplacement. Le dernier algorithme d'approximation, le '''PLURt''', se base sur ce qu'on appelle un arbre de décision. Il a besoin de n − 1 bits pour déterminer la ligne LRU. Ces bits doivent être organisés en arbre, comme illustré plus bas. Chacun de ces bits sert à dire : le LRU est à ma droite ou à ma gauche : il est à gauche si je vaux 0, et à droite si je vaux 1. Trouver le LRU se fait en traversant cet arbre, et en interprétant les bits un par un. Au fur et à mesure des lectures, les bits sont mis à jour dans cet arbre, et pointent plus ou moins bien sur le LRU. La mise à jour des bits s'effectue lors des lectures et écritures : quand une ligne est lue ou écrite, elle n'est pas la ligne LRU. Pour l'indiquer, les bits à 1 qui pointent vers la ligne de cache sont mis à 0 lors de la lecture ou écriture. {| |[[File:Organisation des bits avec l'algorithme PLURt.jpg|vignette|Organisation des bits avec l'algorithme PLURt.]] |[[File:Ligne de cache pointée par les bits de l'algorithme.png|vignette|Ligne de cache pointée par les bits de l'algorithme.]] |} ===LRU amélioré=== L'algorithme LRU, ainsi que ses variantes approximatives, sont très efficaces tant que le programme respecte relativement bien la localité temporelle. Par contre, Le LRU se comporte assez mal dans les circonstances ou la localité temporelle est mauvaise mais où la localité spatiale est respectée, le cas le plus emblématique étant le parcours d'un tableau. Pour résoudre ce problème, des variantes du LRU existent. Une variante très connue, l''''algorithme 2Q''', utilise deux caches : un cache FIFO pour les données accédées une seule fois et un second cache LRU. Évidemment, les données lues une seconde fois sont migrées du cache FIFO vers le cache LRU, ce qui n'est pas très pratique. Les processeurs n'utilisent donc pas cette technique, mais celle-ci est utilisée dans les caches de disque dur. D'autres variantes du LRU combinent plusieurs algorithmes à la fois et vont choisir lequel de ces algorithmes est le plus adapté à la situation. Notre cache pourra ainsi détecter s’il vaut mieux utiliser du MRU, du LRU, ou du LFU suivant la situation. ==Les écritures dans le cache : gestion et optimisations== Les écritures se font à une adresse mémoire bien précise, qui peut ou non être chargée dans le cache. Si la donnée à écrire est chargée dans le cache, elle est modifiée directement dans le cache, mais elle ne l'est pas forcément en mémoire RAM. Suivant le processeur, les écritures sont ou non propagées en mémoire RAM. Il existe deux stratégies d'écritures, appelées respectivement le ''write-back'' et le ''write-through''. Avec un cache ''write-back'', si la donnée à mettre à jour est présente dans le cache, on écrit dans celui-ci sans écrire dans la mémoire RAM. Dans ces conditions, une donnée n'est enregistrée en mémoire que si celle-ci quitte le cache, ce qui évite de nombreuses écritures mémoires inutiles. [[File:Cache write-through.png|centre|vignette|upright=2|Cache write-through.]] Avec les caches '''Write-Through''', toute écriture dans le cache est propagée en RAM. Cette stratégie augmente le nombre d'écritures dans la mémoire RAM, ce qui peut saturer le bus reliant le processeur à la mémoire. Les performances de ces caches sont donc légèrement moins bonnes que pour les caches ''write back''. Par contre, ils sont utiles dans les architectures avec plusieurs processeurs, comme nous le verrons dans les chapitres sur les architectures multiprocesseurs. [[File:Cache write-back.png|centre|vignette|upright=2|Cache write-back.]] ===Les caches ''Write-through''=== Sans optimisation particulière, on ne peut écrire dans un cache ''write-through'' pendant qu'une écriture en RAM a lieu en même temps : cela forcerait à effectuer deux écritures simultanées, en comptant celle imposée par l'écriture dans le cache. Pour éviter cela, certains caches ''write-through'' intègrent un '''tampon d’écriture''', qui sert de file d'attente pour les écritures en RAM. C'est une mémoire FIFO dans laquelle on place temporairement les données à écrire en RAM, où elles attendent en attendant que la RAM soit libre. Grâce à lui, le processeur peut écrire dans un cache même si d'autres écritures sont en attente dans le tampon d'écriture. Par souci d'efficacité, des écritures à la même adresse en attente dans le tampon d’écriture sont fusionnées en une seule. Cela fait un peu de place dans le tampon d’écriture, et lui permet d'accumuler plus d'écritures avant de devoir bloquer le cache. Il est aussi possible de fusionner des écritures à adresses consécutives de la mémoire en une seule écriture en rafales. Dans les deux cas, on parle de '''combinaison d'écriture'''. Mais la technique du tampon d'écriture a cependant un léger défaut qui se manifeste dans une situation bien précise : quand le processeur veut lire une donnée en attente dans le tampon d’écriture. La première manière de gérer cette situation est de mettre en attente la lecture tant que la donnée n'a pas été écrite en mémoire RAM. On peut aussi lire la donnée directement dans le tampon d'écriture, cette optimisation portant le nom de '''''store-to-load forwading'''''. Dans tous les cas, il faut détecter le cas où une lecture accède à une donnée dans le tampon d'écriture. À chaque lecture, l'adresse à lire est envoyée au tampon d'écriture, qui vérifie si une écriture en attente se fait à cette adresse. Pour cela, le tampon d’écriture doit être un cache, dont chaque entrée mémorise une écriture. Chaque ligne de cache contient la donnée à écrire, et le tag de la ligne de cache contient l'adresse où écrire la donnée. Notons que cache d'écriture a une politique de remplacement de type FIFO, le tampon d'écriture non-optimisé étant une mémoire FIFO. ===Les caches ''Write-back''=== Les caches ''write-back'' ont beau avoir des performances supérieures à celles des caches ''write-through'', il existe des optimisations qui permettent d'améliorer leurs performances. Ces optimisations consistent à ajouter des caches spécialisés à côté du cache proprement dit. Ces caches permettent de mémoriser des données qui sont éliminées du cache par les algorithmes de remplacement de ligne cache, sans pour autant faire une écriture en RAM. En suivant la procédure habituelle de remplacement des lignes de cache, on doit rapatrier la ligne en RAM avant d'en charger une nouvelle. On peut améliorer la situation en faisant l'inverse : on charge la nouvelle ligne pendant que l'ancienne donnée est rapatriée en RAM. Ainsi, la nouvelle ligne est disponible plus tôt pour le processeur, diminuant son temps d'attente. Pour implémenter cette technique, on doit mémoriser l'ancienne ligne de cache temporairement dans un '''cache d’éviction''' (ou ''write-back buffer''). [[File:Cache d’éviction.png|centre|vignette|upright=2|Cache d’éviction]] Les caches directement adressés ou associatifs par voie possèdent aussi un tampon d’écriture amélioré. Pour limiter les défauts par conflit de ces caches, des scientifiques ont eu l'idée d'insérer un cache pour stocker les données virées du cache. En faisant ainsi, si une donnée est virée du cache, on peut alors la retrouver dans ce cache spécialisé. Ce cache s'appelle le '''cache de victime'''. Ce cache de victime est géré par un algorithme de suppression des lignes de cache de type FIFO. Petit détail : ce cache utilise un tag légèrement plus long que celui du cache directement adressé au-dessus de lui. L'index de la ligne de cache doit en effet être contenu dans le tag du cache de victime, pour bien distinguer deux adresses différentes, qui iraient dans la même ligne du cache juste au-dessus. [[File:Victim Cache Implementation Example.svg|centre|vignette|upright=1|Cache de victime.]] ===La configuration du fonctionnement du cache=== Sur de nombreux processeurs, il est possible de configurer la mémoire cache pour qu'elle fonctionne soit en mode ''write-back'', soit en mode ''write-through''. Pour cela, les processeurs modernes incorporent des '''registres de configuration du cache'''. Le terme ''registre de configuration du cache'' est assez transparent et indique bien quel est leur rôle. Ils configurent comment le cache est utilisé et permettent notamment de configurer le cache pour dire s'il doit fonctionner en mode ''write-back'' ou ''write-through''. Ils permettent aussi d'activer ou de désactiver la combinaison sur écriture. Les registres en question sont configurés soit par le BIOS, soit par le système d'exploitation. Ce sont des registres protégés, que les applications ne peuvent pas configurer, elles n'en ont pas le droit. Typiquement, ils ne sont accessibles en écriture qu'en mode noyau. Sur les processeurs x86, les registres de configuration du cache sont appelés des '''''Memory type range registers''''' (''MTRRs''). Les MTRRs sont assez nombreux, et il y a notamment une différence entre mode réel et protégé. Si vous vous souvenez des chapitres sur le mode d'adressage et la mémoire virtuelle, vous vous souvenez que les processeurs x86 incorporent plusieurs modes de fonctionnement. En mode réel, le processeur ne peut adresser qu'un mébioctet de RAM, avec un système de segmentation particulier. En mode protégé, le processeur peut adresser toute la mémoire et la segmentation fonctionne différemment, quand elle n'est pas simplement désactivée. Les MTRRs sont séparés en deux : ceux pour le mode réel, ceux pour le mode protégé. Les MTRRs fixes sont ceux qui configurent le cache en mode réel, ils étaient utilisés pour gérer l'accès au BIOS, à la mémoire VGA de la carte graphique, et quelques autres accès aux entrées-sorties basiques gérées nativement par le BIOS. Pour le mode protégé, les processeurs au-delà du 386 incorporent des MTRRs variables, qui servent pour les autres entrées-sorties en général, notamment les périphériques PCI, la mémoire vidéo de la carte graphique, et j'en passe. De nos jours, les registres de configuration du cache sont désuets et cette fonctionnalité est gérée directement par la mémoire virtuelle. La table des pages contient, pour chaque page mémoire, des bits de contrôle qui disent si la page mémoire est cacheable ou non. Le contournement de cache est alors géré par le système de mémoire virtuelle, le cache de TLB et tout ce qui va avec. ===L’allocation sur écriture=== Que faire quand une écriture modifie une donnée qui n'est pas dans le cache ? Doit-on écrire la donnée dans le cache, ou non ? Si la donnée est écrite dans le cache, on dit que le cache fait une '''allocation sur l'écriture''' (ou ''write-allocate''). Certains caches effectuent une telle allocation sur écriture, mais d'autres ne le font pas ou du moins pas systématiquement. L’allocation sur écriture peut se décliner en deux sous-catégories : le '''chargement à la demande''' et l''''écriture immédiate'''. Dans le premier cas, on charge la donnée à modifier dans le cache, et on la remplace avec la donnée écrite. Dans l'écriture immédiate, l'écriture a lieu directement dans le cache et la donnée à modifier n'est pas chargée dans le cache. Évidemment, seule une portion de la ligne de cache contient la donnée écrite (valide), et le reste contient des données invalides. Le cache doit savoir quelles sont les portions du cache qui sont valides : cela demande d'utiliser un ''sector cache''. [[File:Write-back with write-allocation.svg|centre|vignette|upright=2|Cache Write-back avec allocation sur écriture.]] Sans allocation sur écriture, l'écriture est transférée directement aux niveaux de cache inférieurs ou à la mémoire si la donnée à modifier n'est pas dans le cache. Certains caches de ce genre utilisent une petite optimisation : lors de toute écriture, ils supposent que l'écriture donnera un succès de cache. Si c'est le cas, la ligne de cache qui contient la donnée est mise à jour avec la donnée à écrire. Mais si ce n'est pas le cas, la ligne de cache est invalidée, et l'écriture est transférée directement à la mémoire ou aux niveaux de cache inférieurs. [[File:Write-through with no-write-allocation.svg|centre|vignette|upright=2|Cache Write-through sans allocation sur écriture.]] ===La cohérence des caches=== Il arrive parfois que la mémoire d'un ordinateur soit mise à jour, sans que les modifications soient répercutées dans les mémoires cache. Dans ce cas, le cache contient une donnée périmée. Or, un processeur doit toujours éviter de se retrouver avec une donnée périmée et doit toujours avoir la valeur correcte dans ses caches : cela s'appelle la '''cohérence des caches'''. Il est possible de se retrouver avec des valeurs périmées dans le cache sur les ordinateurs avec plusieurs processeurs, ou si un périphérique écrit en RAM, les modifications ne sont pas répercutées automatiquement dans les mémoires cache. Pour résoudre ce problème, on peut interdire de charger dans le cache des données stockées dans les zones de la mémoire dédiées aux périphériques. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires cache. Autre solution : utiliser le fait que les périphériques déclenchent une interruption matérielle pour laisser le contrôleur DMA accéder à la mémoire. Dans ce cas, il suffit de vider les caches à chaque interruption matérielle. Le processeur peut le faire automatiquement, ou fournir des instructions pour. ==Le ''cache bypassing'' : contourner le cache== Dans certaines situations, le cache n'est pas utilisé pour certains accès mémoire. Diverses techniques permettent en effet d'effectuer des accès mémoire qui contournent le cache, qui ne passent pas par le cache. Ils sont utilisés quand l'accès en cache fait que des instructions normales ne fonctionnent pas. Par exemple, de tels accès directs à la RAM sont notamment utilisés pour l'implémentation d'instructions atomiques, une classe d'instructions spécifiques utilisées sur les processeurs multicœurs, dont nous parlerons dans plusieurs chapitres. Mais ils sont aussi utilisés pour l'accès aux périphériques, ce que nous allons voir maintenant. ===Accéder aux périphériques demande de contourner le cache=== Pour rappel, un périphérique (au sens d'entrée-sortie) contient des registres d’interfaçage qui ont une adresse au même titre que les cases mémoire. Un périphérique peut à tout instant modifier ses registres d’interfaçage, ce qui se répercute automatiquement dans l'espace d'adressage, mais rien de tout cela n'est transmis au cache. Si les accès aux périphériques passaient par l'intermédiaire du cache, on aurait droit à des problèmes. On aurait encore une fois droit à des problèmes de cohérence des caches. Le problème est géré différemment suivant que l'on utilise un espace d'adressage séparé ou des entrées-sorties mappées en mémoire. La solution est que les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache. Cela demande d'adapter le cache et le processeur. L'implémentation exacte dépend de comment sont adressés les périphériques. Pour rappel, il y a deux solutions pour adresser les périphériques : soit les périphériques disposent d'un espace d'adressage séparé de celui de la mémoire, soit il y un espace d'adressage unique partagé entre processeur et mémoire. Les deux cas donnent des solutions différentes. Avec un espace d'adressage séparé, l'espace d'adressage des périphériques n'est pas caché : aucun accès dans cet espace d'adressage ne passe par le cache. La mémoire cache n'est utilisée que pour l'espace d'adressage des mémoires, rien d'autre. C'est de loin le cas le plus simple : il suffit de concevoir le processeur pour. Il dispose d'instructions séparées pour les accès aux registres d’interfaçage et à la RAM/ROM, les premières ne passent pas par le cache, les autres si. Avec des entrées-sorties mappées en mémoire, la même solution est utilisée, mais dans une version un peu différente. Là encore, les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache, si on veut qu'ils marchent comme ils le doivent. Cela demande d'adapter le cache et le matériel pour que accès aux périphériques mappés en mémoire contournent le cache. Des adresses, voire des zones entières de la mémoire, sont marquées comme étant non-cachables. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires caches. Là encore, le processeur doit être prévu pour : on doit pouvoir le configurer de manière à marquer certaines zones de la RAM comme non-cacheable. Reste qu'il faut marquer des régions de la RAM comme non-cacheable. Pour cela, on améliore les registres de configuration du cache, vus plus haut, afin qu'ils permettent de configurer certaines portions de la RAM pour préciser qu'elles ne doivent pas être mises en cache, qu'il faut activer le contournement de cache pour celles-ci. ===Contourner le cache pour des raisons de performance=== Il arrive que des données avec une faible localité soient chargées dans le cache inutilement. Or, il vaut mieux que ces données transitent directement entre le processeur et la mémoire, sans passer par l'intermédiaire du cache. Pour cela, le processeur peut fournir des instructions d'accès mémoire qui ne passent pas par le cache, à côté d'instructions normales. De telle instructions sont appelées des '''instructions mémoire non-temporelles'''. Non-temporelle, dans le sens : pas de localité temporelle (c.a.d que les données ne seront pas réutilisées plus tard). Mais il existe aussi des techniques matérielles, où le cache détecte à l'exécution les lectures qui gagnent à contourner le cache. La dernière méthode demande d'identifier les instructions à l'origine des défauts de cache, le processeur accédant directement à la RAM quand une telle instruction est détectée. Si une instruction d'accès mémoire fait trop de défauts de cache, c'est signe qu'elle gagne à contourner le cache. L'idée est de mémoriser, pour chaque instruction d'accès mémoire, un historique de ses défauts de cache. Il existe plusieurs méthodes pour cela, mais toutes demandent d'ajouter de quoi mémoriser l'historique des défauts de cache des instructions. L'historique est mémorisé dans une mémoire appelée la '''table d’historique des défauts de lecture''' (''load miss history table''), qui est souvent un cache. L'historique en question est, dans sa version la plus simple, un compteur de quelques bits incrémenté à chaque succès de cache et décrémenté à chaque défaut de cache, qui indique si l'instruction a en moyenne fait plus de défauts ou de succès de cache. La table associe le ''program counter'' d'une instruction mémoire à cet historique. À la première exécution d'une instruction d'accès mémoire, une entrée de cette table est réservée pour l'instruction. Lors des accès ultérieurs, le processeur récupérer les informations associées et décide s'il faut contourner le cache ou non. ==La hiérarchie mémoire des caches== [[File:Cache Hierarchy.png|vignette|Hiérarchie de caches]] On pourrait croire qu'un seul cache est largement suffisant pour compenser la lenteur de la mémoire. Hélas, les processeurs sont devenus tellement rapides que les caches sont eux-mêmes très lents ! Pour rappel, plus une mémoire peut contenir de données, plus elle est lente. Et les caches ne sont pas épargnés. Si on devait utiliser un seul cache, celui-ci serait très gros et donc trop lent. La situation qu'on cherche à éviter avec la mémoire RAM revient de plus belle. Même problème, même solution : si on a décidé de diviser la mémoire principale en plusieurs mémoires de taille et de vitesse différentes, on peut bien faire la même chose avec la mémoire cache. Depuis environ une vingtaine d'années, un processeur contient plusieurs caches de capacités très différentes : les caches L1, L2 et parfois un cache L3. Certains de ces caches sont petits, mais très rapides : c'est ceux auxquels on va accéder en priorité. Viennent ensuite d'autres caches, de taille variable, mais plus lents. Les processeurs ont donc une hiérarchie de caches qui se fait de plus en plus complexe avec le temps. Cette hiérarchie est composée de plusieurs niveaux de cache, qui vont des niveaux inférieurs proches de la mémoire RAM à des niveaux supérieurs proches du processeur. Plus on monte vers les niveaux supérieurs, plus les caches sont petits et rapides. Un accès mémoire dans une hiérarchie de cache fonctionne comme suit : on commence par vérifier si la donnée recherchée est dans le cache le plus rapide, à savoir le cache L1. Si c'est le cas,n on la charge depuis ce cache directement. Si elle n’y est pas, on vérifie si elle est dans le cache de niveau supérieur, le cache L2. Et rebelote ! Si elle n'y est pas, on vérifie le cache du niveau supérieur. Et on répète cette opération, jusqu’à avoir vérifié tous les caches. Si la donnée n'est dans aucun cache, on doit alors aller chercher la donnée en mémoire. [[File:Hiérarchie de caches.png|centre|vignette|upright=2|Hiérarchie de caches]] Il y a des différences assez notables entre chaque niveau de cache. Par exemple, les différents niveaux de cache n'ont pas forcément les mêmes politiques de remplacement des lignes de cache. Le cache L1 a généralement une politique de remplacement simple, très rapide, mais peu efficace. De même, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Le cache le plus proche de la mémoire est appelé le '''cache de dernier niveau''', ''Last Level Cache'' en anglais. Il a parfois des caractéristiques totalement différentes des autres caches. Par exemple, sur les processeurs multicoeurs, le cache L3 n'est techniquement pas dans le cœur, mais fait partie d'un ensemble de circuits reliés, comme le contrôleur mémoire ou l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc. ===Les caches exclusifs et inclusifs=== Notons que du point de vue de cette vérification, il faut distinguer les caches inclusifs et exclusifs. Avec les caches inclusifs, si une donnée est présente dans un cache, alors elle est présente dans les caches des niveaux inférieurs, ce qui implique l'existence de données en doublon dans plusieurs niveaux de cache. À l'opposé, les caches exclusifs font que toute donnée est présente dans un seul cache, pas les autres. Il existe aussi des caches qui ne sont ni inclusifs, ni exclusifs. Sur ces caches, chaque niveau de cache gère lui-même ses données, sans se préoccuper du contenu des autres caches. Pas besoin de mettre à jour les niveaux de cache antérieurs en cas de mise à jour de son contenu, ou en cas d'éviction d'une ligne de cache. La conception de tels caches est bien plus simple. Dans les '''caches exclusifs''', le contenu d'un cache n'est pas recopié dans le cache de niveau inférieur. Il n'y a pas de donnée en double et on utilise 100 % de la capacité du cache, ce qui améliore le taux de succès. Par contre, le temps d'accès est un peu plus long. La raison est que si une donnée n'est pas dans le cache L1, on doit vérifier l'intégralité du cache L2, puis du cache L3. De plus, assurer qu'une donnée n'est présente que dans un seul cache nécessite aux différents niveaux de caches de communiquer entre eux pour garantir que l'on a pas de copies en trop d'une ligne de cache, ce qui peut prendre du temps. [[File:Caches exclusifs.png|centre|vignette|upright=2|Caches exclusifs]] Dans le cas des '''caches inclusifs''', le contenu d'un cache est recopié dans les caches de niveau inférieur. Par exemple, le cache L1 est recopié dans le cache L2 et éventuellement dans le cache L3. Ce genre de cache a un avantage : le temps d'accès à une donnée est plus faible. La raison est qu'il ne faut pas vérifier tout un cache, mais seulement la partie qui ne contient pas de donnée en doublon. Par exemple, si la donnée voulue n'est pas dans le cache L1, on n'est pas obligé de vérifier la partie du cache L2 qui contient la copie du L1. Ainsi, pas besoin de vérifier certaines portions du cache, ce qui est plus rapide et permet de simplifier les circuits de vérification. En contrepartie, l'inclusion fait que qu'une partie du cache contient des copies inutiles, comme si le cache était plus petit. De plus, maintenir l'inclusion est compliqué et demande des circuits en plus et/ou des échanges de données entre caches. [[File:Caches inclusifs.png|centre|vignette|upright=2|Caches inclusifs]] Maintenir l'inclusion demande de respecter des contraintes assez fortes, ce qui ne se fait pas facilement. Premièrement, toute donnée chargée dans un cache doit aussi l'être dans les caches de niveau inférieur. Ensuite, quand une donnée est présente dans un cache, elle doit être maintenue dans les niveaux de cache inférieurs. De plus, toute donnée effacée d'un cache doit être effacée des niveaux de cache supérieurs : si une donnée quitte le cache L2, elle doit être effacée du L1. Ces trois contraintes posent des problèmes si chaque cache décide du remplacement des lignes de cache en utilisant un algorithme comme LRU, LFU, MRU, ou autre, qui utilise l'historique des accès. En effet, dans ce cas, le cache décide de remplacer les lignes de cache selon l'historique des accès, historique qui varie suivant chaque niveau de cache. Par exemple, une donnée rarement utilisée dans le L2 peut parfaitement être très fréquemment utilisée dans le L1 : la donnée sera alors remplacée dans le L2, mais sera maintenue dans le L1. On observe aussi des problèmes quand il existe plusieurs caches à un seul niveau : chaque cache peut remplacer les lignes de cache d'une manière indépendante des autres caches du même niveau, donnant lieu au même type de problème. Pour maintenir l'inclusion, les caches doivent se transmettre des informations qui permettent de maintenir l'inclusion. Par exemple, les caches de niveaux inférieurs doivent prévenir les niveaux de cache supérieurs quand ils remplacent une ligne de cache. De plus, toute mise à jour dans un cache doit être répercutée dans les niveaux de cache inférieurs et/ou supérieurs. On doit donc transférer des informations de mise à jour entre les différents niveaux de cache. Généralement, le contenu des caches d'instruction n'est pas inclus dans les caches de niveau inférieurs, afin d'éviter que les instructions et les données se marchent sur les pieds. Enfin, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Dans ce cas, l'inclusion est plus difficile à maintenir, pour des raisons assez techniques. ===Les caches eDRAM, sur la carte mère et autres=== D'ordinaire, les mémoires caches sont intégrées au processeur, à savoir que cache et CPU sont dans le même circuit imprimé. Les caches sont donc fabriqués avec de la SRAM, seule forme de mémoire qu'on peut implémenter dans un circuit intégré. Intégrer tous les caches dans le processeur est une solution et efficace. Mais certains processeurs ont procédé autrement. [[File:Cache-on-a-stick module.jpg|vignette|Cache-on-a-stick module]] Des processeurs assez anciens incorporaient un cache L1 dans le processeur, mais plaçaient un cache L2 sur la carte mère. Le cache était clippé sur un connecteur sur la carte mère, un peu comme le sont les barrettes de mémoire. On parlait alors de '''''Cache on a stick''''' (COAST). On aurait pu s'attendre à ce que de tels caches soient en DRAM, vu qu'ils sont placés sur des barrettes de RAM, mais la ressemblance avec la mémoire RAM principale s'arrête là. Le cache était fabriqué en mémoire SRAM, même s'il est en théorie possible de faire de tels caches avec de la DRAM. Les premiers processeurs avec un cache faisaient ainsi, au début des années 90. Il a été introduiot sur les processeurs Motorola, et a été utilisé sur les IBM PC et les Macintosh de l'époque. Les ordinateurs Macintosh utilisaient de tels caches, pour la pluaprt des modèles. Pour ce qui est des PC, les premiers processeurs x86 faisaient pareil, notamment les processeurs Intel. Le 486, le Pentium et le Pentium 2 utilisaient des ''Cache on a stick''. L'avantage est que cela permettait de mettre plus de cache, à une époque où les circuits étaient limités en transistors. De plus, cela permettait au consommateur de choisir quelle quantité de cache il voulait, selon ses finances. Il était possible de laisser le processeur fonctionner soit sans mémoire cache, soit avec un cache de 256 Kibioctets, de 512 Kibioctets, etc. Il était possible d'upgrader le cache si besoin. Pour les CPU Intel, le cache était connecté sur le bus système, au même titre que la mémoire RAM et les entrées-sorties. Il faut dire que les processeurs de l'époque utilisaient un bus système et n'avaient pas de bus mémoire dédié. Mais en théorie, rien n’empêche de connecter le cache sur un bus mémoire dédié. Toujours est-il que les lectures et écritures étaient propagées à la fois dans le cache et la RAM. Les écritures se faisaient dans les deux, systématiquement dans la RAM, mais aussi dans le cache en cas de succès de cache. Les lectures étaient servies soit par le cache en cas de succès de cache, soit par la RAM en cas de défaut de cache. Si le cache répondait en premier, la transaction sur le bus se terminait précocement et l'accès en RAM était abandonné. [[File:Intel486 Иерархия памяти.png|centre|vignette|upright=2.5|Intel486 : le cache était connecté sur le bus système.]] À l'inverse, certains processeurs possédaient un cache fabriqué en mémoire DRAM, et plus précisément avec de la mémoire eDRAM. Le cache n'était pas intégré dans le même circuit imprimé que le processeur, mais profitait d'une architecture en ''chiplet''. Pour rappel, cela veut dire que le processeur est en réalité composé de plusieurs circuits intégré séparés, mais interconnectés et soudés sur un même PCB carré. Avec un cache en eDRAM, le cache avait son propre circuit intégré, séparé du circuit intégré du processeur ou du circuit intégré pour le contrôleur mémoire/IO. Un exemple est celui du cache des processeurs Intel de microarchitecture Broadwell, vus dans ce chapitre dans la section sur les caches splittés. Les tags étaient intégrés dans le circuit intégré du processeur, mais les données étaient mémorisées dans une puce d'eDRAM séparée. La puce eDRAM correspondait en réalité à une DRAM adressable qui servait de DRAM pour les données et mémorisaient les voies du cache. ==Les caches adressés par somme et hashés== Les caches adressés par somme sont optimisés pour incorporer certains calculs d'adresse directement dans le cache lui-même. Pour rappel, certains modes d'adressage impliquent un calcul d'adresse, qui ajoute une constante à une adresse de base. Généralement, l'adresse de base est l'adresse d'un tableau ou d'une structure, et la constante ajoutée indique la position de la donnée dans le tableau/la structure. Les caches hashés et les caches adressés par somme permettent de faire l'addition directement dans la mémoire cache. Voyons d'abord les caches hashés, avant de passer aux caches adressés par somme. Sur les '''caches hashés''', l'addition est remplacée par une autre opération, par exemple des opérations bit à bit du style XOR, AND ou OR, etc. Seulement, utiliser des opérations bit à bit pose un problème : il arrive que deux couples Adresse/décalage donnent le même résultat. Par exemple, le couple Adresse/décalage 11101111/0001 donnera la même adresse que le couple 11110000/0000. Dit autrement, deux adresses censées être différentes (après application du décalage) sont en réalité attribuées à la même ligne de cache. Il est toutefois possible de gérer ces situations, mais cela demande des astuces de haute volée pour faire fonctionner la mémoire cache correctement. Sur les '''caches adressés par somme''', le décodeur est modifié pour se passer de l'addition. Pour comprendre comment, il faut rappeler qu'un décodeur normal est composé de comparateurs, qui vérifient si l'entrée est égale à une constante bien précise. Sur un cache ordinaire, l'addition est faite séparément du décodage des adresses par le cache, dans l'unité de calcul ou dans l'unité de génération d'adresse. [[File:Non sum adressed cache.png|centre|vignette|upright=2|Cache normal.]] Mais les caches adressés par somme modifient le décodeur, qui est alors composé de comparateurs qui testent si la somme adresse + décalage est égale à une constante. [[File:Cache adressé par somme.png|centre|vignette|upright=2|Cache adressé par somme.]] Chaque circuit du décodeur fait le test suivant, avec K une constante qui dépend du circuit : : <math>A + B = K</math> Ce qui est équivalent à faire le test suivant : : <math>A + B - K = 0</math> En complément à deux, on a <math>- K = \overline{K} + 1</math>. En injectant dans l'équation précédente, on a : : <math>A + B + \overline{K} + 1 = 0</math> En réorganisant les termes, on a : : <math>A + B + \overline{K} = - 1</math> Il suffit d'utiliser un additionneur ''carry-save'' pour faire l'addition des trois termes. Rappelons qu'un tel additionneur fournit deux résultats en sortie : une somme calculée sans propager les retenues et les retenues en question. Notons que les retenues sont à décaler d'un cran, vu qu'elles sont censées s'appliquer à la colonne suivante. En notant la somme S et les retenues R, on a: : <math>S + (R << 1) = - 1 </math>, le décalage d'un cran à gauche étant noté <math><< 1</math>. Ensuite, -1 est codé avec un nombre dont tous les bits sont à 1 en complément à un/deux. : <math>S + (R << 1) = 111 \cdots 111111</math> [[File:Sum + retenue add.png|centre|vignette|upright=2|Sum + retenue add]] Un simple raisonnement nous permet de savoir si le résultat est bien -1, sans faire l'addition <math>S + (R << 1)</math>. En effet, on ne peut obtenir -1 que si la somme est l'inverse des retenues : un 0 dans le premier nombre correspond à un 1 dans l'autre, et réciproquement. En clair, on doit avoir <math>\overline{S} = R << 1</math>. Pour vérifier cela, il suffit de faire un simple XOR entre la somme et les retenues décalées d'un cran. On a alors : : <math>S \oplus (R << 1) = 111 \cdots 111111</math> La comparaison avec -1 se fait avec une porte ET à plusieurs entrées. En effet, la porte donnera un 1 seulement si tous les bits d'entrée sont à 1, ce qui est ce qu'on veut tester. Au final, l'additionneur pour l'addition adresse + décalage est remplacé par un additionneur carry-save suivi d'une couche de portes XOR et d'un comparateur avec une constante, ce qui économise de circuits et améliore les performances. [[File:Final circuit of sum addressed cache.png|centre|vignette|upright=2|Cache adressé par somme.]] En prenant en compte que la constante K est justement une constante, certaines entrées de l'additionneur carry-save sont toujours à 0 ou à 1, ce qui permet quelques simplifications à grand coup d’algèbre de Boole. Chaque additionneur complet qui compose l’additionneur carry-save est remplacée par des demi-additionneurs (ou par un circuit similaire). Autant dire que l'on gagne tout de même un petit peu en rapidité, en supprimant une couche de portes logiques. Le circuit de décodage économise aussi des portes logiques, ce qui est appréciable. ==Les caches à accès uniforme et non-uniforme== Intuitivement, le temps d'accès au cache est le même pour toutes les lignes de cache. Il s'agit de cache appelés '''caches à accès uniforme''', sous-entendu à temps d'accès uniforme. Mais sur les caches de grande capacité, il arrive souvent que le temps de propagation des signaux varie fortement suivant la ligne de cache à lire. D'ordinaire, on se cale sur la ligne de cache la plus lente pour caler la fréquence d'horloge du cache, même si on pourrait faire mieux. Cependant, les '''caches à accès non uniforme''' ont une latence différente pour chaque ligne d'un même cache. Certaines lignes de cache sont plus rapides que d'autres. Niveau terminologie, nous allons parler de caches UCA et NUCA : ''Uniform Access Cache'' pour les caches à accès uniforme, ''Non-Uniform Access Cache'' pour les caches à accès non-uniforme. [[File:Caches UCA et NUCA.png|vignette|Caches UCA et NUCA.]] Les caches NUCA et UCA sont souvent composés de plusieurs banques séparées, typiquement une par voie. Sur les caches UCA, les banques sont interconnectées avec le processeur de manière à ce que toutes les interconnexions ont la même longueur pour toutes les banques. Typiquement, les banques sont organisées en carré, avec les interconnexions qui partent du centre, avec une disposition en H, illustrée ci-contre Mais avec les caches NUCA, ce n'est pas le cas. Les interconnexions sont simplifiées et ont des longueurs différentes. Les caches NUCA n'ont pas tous le même genre d'interconnexions, qui dépendent du cache NUCA. En général, les interconnexion forme un réseau avec des sortes de routeurs qui redirigent les données/commandes vers la bonne destination : cache ou processeur. Les banques plus proches du processeur sont accessibles plus rapidement que celles éloignées, même si la différence n'est pas énorme. Les caches NUCA sont généralement associatifs par voie. Les plus simples utilisent une banque par voie pour le cache, ce qui fait que certaines voies répondent plus vite que les autres. La détection des succès de cache est alors plus rapide si la donnée lue/écrite est dans une voie/banque rapide. En théorie, les défauts de cache demandent de vérifier toutes les banques, et se calent donc sur la pire latence. Mais divers caches se débrouillent pour que ce ne soit pas le cas, soit en vérifiant les banquyes unes par une, soit par un mécanisme de recherche plus complexe. Les caches NUCA sont surtout utilisés pour les caches L3 et L4, éventuellement les caches L2. Les caches L1 sont systématiquement des caches UCA, car la latence de l'accès au cache L1 est utilisée par le processeur pour décider quand lancer les instructions. Pour simplifier, le processeur peut démarrer en avance une instruction avant qu'une opérande soit lue dans le cache L1, de manière à ce que la donnée arrive en entrée de l'ALU pile en même temps que l'instruction. Une histoire d'exécution dans le désordre et d'émission anticipée des instructions qu'on détaillera dans une bonne dizaine de chapitres. Toujours est-il que tout est plus simple pour le processeur si le cache L1 a un temps d'accès fixe. Par contre, les caches L3 et L4 sont traités en attendant que les données arrivent, le processeur reprend l'exécution des instructions quand les caches L3 et L4 ont terminé de répondre, pas avant. Avec l'association une banque = une voie, la correspondance ligne de cache → bloc de mémoire qui est statique : on ne peut pas déplacer le contenu d'une ligne de cache dans une autre portion de mémoire plus rapide suivant les besoins. Mais la recherche académique a étudié le cas où la correspondance entre une ligne de cache et une banque varie à l’exécution. Pour nommer cette distinction, on parle de caches S-NUCA (''Static NUCA'') et D-NUCA (''Dynamic NUCA''). Intuitivement, on s'attend à ce que les caches D-NUCA soient plus performants que les caches S-NUCA. Les lignes de cache les plus utilisées peuvent migrer dans une banque rapide, alors que les lignes de cache moins utilisées vont dans une banque éloignée. Les lignes de cache se répartissent dans le cache dynamiquement dans les banques où elles sont le plus adaptées. Mais paradoxalement, le gain des caches D-NUCA est presque nul, voire insignifiant. La raison est que les caches D-NUCA doivent incorporer un système pour déterminer dans quelle banque se situe la donnée pour détecter les succès/défauts de cache, ainsi qu'un système pour migrer les données entre banques. Et ce système augmente le temps d'accès au cache, réduisant à néant l'intérêt d'un cache D-NUCA. Si on économise quelques microsecondes de temps d'accès en passant d'un cache UCA à un cache S-NUCA, ce n'est pas pour les perdre en passant à un D-NUCA. La majorité des caches D-NUCA sont donc en cours de recherche, mais ne sont pas utilisés en pratique. ==La tolérance aux erreurs des caches== Une mémoire cache reste avant tout une mémoire RAM, bien que ce soit de la SRAM. Elle n'est pas parfaite et est donc sujette à des erreurs, qui peuvent inverser un bit ou l'effacer. De telles erreurs sont liées à des rayons cosmiques très énergétiques, à des particules alpha produites par le packaging ou le métal deu circuit intégré, peu importe : l'essentiel est qu'ils inversent parfois un bit. Les mémoires modernes savent se protéger contre de telles erreurs, en utilisant trois moyens. ===Les mémoires caches ECC et à bit de parité=== Le premier moyen est l'usage de codes correcteurs d'erreurs, qui ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Les bits ajoutés dépendent de la donnée mémorisée dans le byte, et servent à détecter une erreur, éventuellement à la corriger. Le cas le plus simple ajoute un simple bit de parité pour chaque byte et se contente de détecter les erreurs dans les corriger. Les autres codes ECC permettent eux de corriger des erreurs, mais ils demandent d'ajouter au moins deux bits par byte, ce qui a un cout en circuit plus élevé. Un simple bit de parité permet de détecter qu'un bit a été inversé, mais ne permet pas de corriger l'erreur. En soi, ce n'est pas un problème. Si une erreur est détectée, on considère que la ligne de cache est invalide. Le cache gère la situation comme un défaut de cache et va chercher la donnée valide en mémoire RAM. Le cout en circuits est donc faible, mais les défauts de cache sont plus nombreux. Les codes ECC sont eux capables de corriger les erreurs, si elles ne modifient pas trop de bits d'un coup. Par contre, ils utilisent deux à trois bits par octet, ce qui a un cout en circuits loin d'être négligeable. Il y a donc un compromis entre défauts de cache et cout en circuits. La gestion de l'ECC est différente suivant le niveau de cache. Généralement, le cache L1 n'utilise pas l'ECC mais se contente d'un simple bit de parité pour éviter la corruption de ses données. Le cache étant petit, les corruptions de données sont assez rares, et les défauts de cache induits faibles. Il est plus important d'utiliser un code de détection d'erreur simple, rapide, qui ne ralentit pas le cache et n'augmente pas sa latence. Si une ligne de cache est corrompue, il a juste à aller lire la ligne depuis le cache L2, ou un niveau de cache inférieur. Du moins, c'est possible sur le cache en question est un cache inclusif et/ou ''write-through''. Par contre, le niveau de cache L2 et ceux en-dessous utilisent presque systématiquement une mémoire SRAM ECC. La raison principale étant que ce sont des caches assez gros, pour lesquels la probabilité d'une erreur est assez élevée. Plus une mémoire a de bits et prend de la place, plus il y a une chance élevée qu'un bit s'inverse. Et vu que les caches L2/L3/L4 sont par nature plus lents et plus gros, ils peuvent se permettre le cout en performance lié à l'ECC, idem pour le cout en circuit. Sans compter qu'en cas d'erreur, ils doivent aller lire la ligne de cache originelle en mémoire RAM, ce qui est très lent ! Mieux vaut corriger l'erreur sur place en utilisant l'ECC. ===L'usage du ''memory scrubbing'' sur les caches=== La plupart des erreurs ne changent qu'un seul bit dans un byte, mais le problème est que ces erreurs s'accumulent. Entre deux accès à une ligne de cache, il se peut que plusieurs erreurs se soient accumulées, ce qui dépasse les capacités de correction de l'ECC. Dans ce cas, il existe une solution appelée le ''memory scrubbing'', qui permet de résoudre le problème au prix d'un certain cout en performance. Pour rappel, l'idée est de vérifier les lignes de caches régulièrement, pour éviter que les erreurs s'accumulent. Par exemple, on peut vérifier chaque ligne de cache toutes les N millisecondes, et corriger une éventuelle erreur lors de cette vérification. En faisant des vérifications régulières, on garantir que les erreurs n'ont pas le temps de s'accumuler, sauf en cas de malchance avec des erreurs très proches dans le temps. Il ne s'agit pas d'un rafraichissement mémoire, car les SRAM ne s'effacent pas), mais ça a un effet similaire. Et évidemment, le ''memory scrubbing'' a un cout en performance. On peut faire une comparaison avec le rafraichissement mémoire : les rafraichissement réguliers réduisent les performances, car cela fait des accès en plus. Des accès qui sont de plus timés à des instants bien précis qui ne sont pas forcément les plus adéquats. Il est possible qu'un rafraichissement ait lieu en même temps qu'un accès mémoire et le rafraichissement a la priorité, ce qui réduit les performances. La même chose arrive avec les vérifications du ''memory scrubbing''. Malgré tout, la technique a été utilisée sur les caches de certains processeurs commerciaux, dont des processeurs AMD Athlon et Athlon 64. Elle est surtout utilisable sur les caches L2/L3, pour lesquels le cout du pseudo-rafraichissement est acceptable. ==Un exemple de cache : le cache d'instruction== La grande majorité des processeurs utilise deux caches L1 séparés : un '''cache d'instructions''' dédié aux instructions, et un autre pour les données. Une telle organisation permet de charger une instruction tout en lisant une donnée en même temps. Notons que seul le cache L1 est ainsi séparé entre cache de données et d'instructions. Le cache d’instruction se situe en théorie entre l'unité de chargement et l'unité de décodage. En effet, ce cache prend en entrée une adresse et fournit une instruction. L'adresse est fournie par le ''program counter'', l'instruction est envoyée dans l'unité de décodage. Le cache se situe donc entre les deux. Le cache de données L1 est connecté au chemin de données, et notamment aux unités de communication avec la mémoire, pas au séquenceur. [[File:Caches L1 et positions dans le processeur.png|centre|vignette|upright=2.5|Caches L1 et positions dans le processeur]] Les deux caches sont reliés au processeur par des bus séparés, l'ensemble ressemble à une architecture Harvard, mais où les caches remplacent les mémoires RAM/ROM. Le cache d'instruction prend la place de la mémoire ROM et le cache de données prend la place de la mémoire RAM. Évidemment, il y a des niveaux de caches en dessous des caches de données/instruction, et ceux-ci contiennent à la fois données et instructions, les deux ne sont pas séparées dans des mémoires/caches séparés. Raison pour laquelle l'ensemble est appelé une '''architecture Harvard modifiée'''. Architecture Harvard, car l'accès aux données et instructions se font par des voies séparées pour le processeur, modifiée car la séparation n'est effective que pour le cache L1 et pas les autres niveaux de cache, et encore moins la RAM. Sur les processeurs modernes, il arrive très souvent que le processeur doive charger une instruction et lire/écrire une donnée en même temps. Et à vrai dire, c'est la règle plus que l'exception. L'usage d'une architecture Harvard modifiée permet cela très facilement : on peut accéder au cache d'instruction via un bus, et au cache de donnée avec l'autre ===Pourquoi scinder le cache L1 en cache d'instruction et de données=== L'usage d'un cache d’instruction séparé du cache de données est à contraster avec l'usage d'un cache L1 multiport unique, capable de mémoriser à la fois instructions et données. Les deux solutions sont possibles ont été utilisées. Les premiers processeurs avaient un cache L1 unique et multiport, mais ce n'est plus le cas sur les processeurs modernes, car les contraintes ne sont pas les mêmes. Le compromis à faire est celui entre deux petits caches rapides et un gros cache plus lent. Pour rappel, plus un cache est petit, plus il est rapide et chauffe moins. Donc au lieu d'utiliser, par exemple, un gros cache lent de 64 Kibioctets, on utilise deux caches de 32 kibioctets, plus rapides. La capacité totale est la même, mais le temps d'accès plus faible. En termes de temps d'accès, la meilleure solution est celle des deux caches simple port. Mais pour ce qui est de l'économie de circuits, c'est moins évident. Entre deux mémoires simple port et une mémoire multiport, la différence en termes de transistors est ambigüe et dépend de la capacité des caches. La différence est surtout notable pour les gros caches, moins pour les petits caches. Il faut aussi tenir compte de la capacité effective. Avec deux caches séparés, la répartition de la capacité du cache L1 est fixée une bonne fois pour toutes. Par exemple, avec un cache d'instruction de 32 KB et un cache de données de 32 KB, impossible d'allouer 40 KB aux données et 20 aux instructions. Alors qu'avec un cache L1 unique de 64 KB, on pourrait le faire sans soucis. La répartition se fait naturellement, en fonction de la politique de remplacement du cache et est proche de l'optimal. C'est là un désavantage des caches d'instructions/données séparés : une capacité effective moindre. Tout cela explique pourquoi le cache L1 est le seul à être ainsi scindé en deux, avec une séparation entre instructions et données : les contraintes au niveau du cache L1 et L2 ne sont pas les mêmes. Pour les caches L1, le temps d'accès est plus important que la capacité, ce qui favorise les caches séparés. Par contre, pour les caches L2/L3/L4, le temps d'accès n'est pas déterminant, alors que la capacité effective et l'économie en circuits sont significatives. ===La connexion des caches L1 avec le cache L2=== Pour les connexions avec le cache L2, tout dépend du processeur. Certains utilisent un cache L2 multiport, qui permet aux deux caches L1 de lire ou écrire dans le cache L2 simultanément. [[File:Cache d'instructions.png|centre|vignette|upright=1.5|Cache d'instructions.]] Si le cache L2 ne gère pas les accès simultanés, il n'y a qu'un seul bus relié aux caches L1 et au cache L2. On doit effectuer un arbitrage pour décider quel cache a la priorité, chose qui est réalisé par un circuit d'arbitrage spécialisé. [[File:Circuit d'arbitrage du cache.png|centre|vignette|upright=1.5|Circuit d'arbitrage du cache.]] Généralement, les caches d'instructions peuvent se permettre d'être plus petits que les caches de données, car les programmes sont souvent plus petits que les données manipulées. Songez que des programmes de quelques mébioctets peuvent parfois remplir la RAM avec plusieurs gibioctets de données. Lancez votre navigateur internet et ouvrez une page web un peu chargée, pour vous en convaincre ! ===Les spécificités du cache d'instruction : lecture seule, bloquant, etc=== Les instructions sont rarement modifiées ou accédées en écritures, contrairement aux données. Et cela permet d'utiliser un cache simplifié pour les instructions. Autant un cache généraliste doit permettre les lectures et écritures depuis le processeur (avec les échanges avec la RAM), autant un cache d'instruction peut se contenter des lectures provenant du CPU et des échanges avec la RAM. Le cache d'instructions est donc très souvent en « lecture seule » : le processeur ne peut pas écrire dedans, mais juste le lire ou charger des instructions dedans. Un cache d'instruction est donc plus simple qu'un cache pour les données : on peut retirer les circuits en charge de l'écriture (mais on doit laisser un port d'écriture pour charger les instructions dedans). Le gain en circuits permet d'utiliser un cache d'instruction plus gros ou au contraire de laisser de la place pour le cache de données. Le gain en termes de capacité compense alors un peu les inconvénients des caches séparés. Par contre, cela complique la gestion du code automodifiant, c'est-à-dire des programmes dont certaines instructions vont aller en modifier d'autres, ce qui sert pour faire de l'optimisation ou est utilisé pour compresser ou cacher un programme (les virus informatiques utilisent beaucoup de genre de procédés). Quand le processeur exécute ce genre de code, il ne peut pas écrire dans ce cache L1 d'instructions, mais doit écrire dans le cache L2 ou en RAM, avant de recharger les instructions modifiées dans le cache L1. Cela qui prend du temps et peut parfois donner lieu à des erreurs si le cache L1 n'est pas mis à jour. Les algorithmes de remplacement des lignes de cache optimaux pour les données ne le sont pas pour les instructions, de même que la taille optimale du cache, la taille des lignes de cache optimale, ou même les algorithmes de préchargement. Par exemple, pour le remplacement des lignes de cache, un simple algorithme LRU est presque optimal pour les instructions, autant il peut donner de mauvaises performances quand on manipule beaucoup de tableaux. Cela justifie d'utiliser des caches spécialisés pour chacune. On peut adapter le cache d'instruction à son contenu, ce qui le rend plus rapide ou plus petit à performance égale. Les caches d'instructions sont généralement des caches bloquants. Il ne servirait à rien de rendre un cache d'instruction non-bloquant, le cout en circuits ne se traduirait pas par une augmentation significative des performances. À l'opposé, les caches de données sont non-bloquants sur les architectures modernes, pour des raisons de performance. Ce qui rend la séparation assez intéressante, les deux caches ayant des besoins différents et des implémentations différentes, cela permet d'optimiser le cout en transistors des caches. ===L'impact du cache d'instruction sur les performances=== Sur les architectures conventionnelles, le cache d'instruction a plus d'impact sur les performances que le cache de données. La raison principale est que les instructions ont une meilleure localité spatiale et temporelle que pour les données. Pour la localité spatiale, les instructions consécutives se suivent en mémoire, alors que rien ne garantit que des données utilisées ensemble soient regroupées en mémoire. Pour localité temporelle, elle est très variable pour les données, mais très courante pour les instructions du fait de l'usage fréquent des boucles et des fonctions. : La présence de branchements atténue la localité temporelle des instruction, sauf que la majorité des branchements sautent à un endroit très proche, seuls les appels de fonction brisent la localité spatiale. La conséquence est qu'il arrive que certains CPU aient un cache L1 d'instruction plus gros que celui pour les données. On parle alors de '''cache L1 asymétriques'''. Un exemple est celui des processeurs AMD de microarchitecture Zen, dont le cache d'instruction était deux fois plus gros que le cache de données. Leur cache d'instruction faisait 64 kibioctets, contre seulement 32 pour le cache de données. D'ailleurs, il existe des processeurs assez extrêmes qui se contentent d'un cache d'instruction unique, sans cache de données. C'est le cas sur les processeurs vectoriels ou les GPU que nous verrons dans les chapitres de fin de ce wikilivres. De tels processeurs sont spécialisés dans la manipulation de tableaux de données, traitement qui a une faible localité temporelle. En conséquence, utiliser un cache de données n'est pas vraiment utile, voire peu être contreproductif, alors qu'un cache d’instruction fonctionne parfaitement. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Compléments sur les mémoires de masse | prevText=Compléments sur les mémoires de masse | next=Le préchargement | nextText=Le préchargement }} </noinclude> 2ymeqkgvollke7m02yaah0bbxhf2mnc 765116 765115 2026-04-26T14:53:36Z Mewtow 31375 /* L'exemple des processeurs 386 et du contrôleur de cache 82385 */ 765116 wikitext text/x-wiki Le cache est une mémoire intercalée entre la mémoire et un processeur, plus rarement à l'intérieur d'un périphérique. Il est souvent fabriquée avec de la mémoire SRAM, parfois avec de l'eDRAM. Sans lui, on se croirait à l'âge de pierre tellement nos PC seraient lents ! En effet, la mémoire est très lente comparée au processeur. Le temps mis pour accéder à la mémoire est du temps durant lequel le processeur n'exécute pas d'instruction (sauf cas particuliers impliquant un pipeline). Pour diminuer ce temps d'attente, il a été décidé d'intercaler une mémoire petite mais rapide, entre le processeur et la mémoire. Ainsi, le processeur accède à un cache très rapide plutôt qu'à une RAM beaucoup plus lente. ==L'accès au cache== Le cache contient une copie de certaines données présentes en RAM. La copie présente dans le cache est accessible bien plus rapidement que celle en RAM, vu que le cache est plus rapide. Mais seule une petite partie de ces données sont copiées dans le cache, les autres données devant être lues ou écrites dans la RAM. Toujours est-il que le cache contient une copie des dernières données accédées par le processeur. Une donnée est copiée dans la mémoire cache quand elle est lue ou écrite par le processeur. Le processeur conserve une copie de la donnée dans le cache après son premier accès. Les lectures/écritures suivantes se feront alors directement dans le cache. Évidemment, au fur et à mesure des accès, certaines données anciennes sont éliminées du cache pour faire de la place aux nouveaux entrants, comme nous le verrons plus tard. [[File:Principe d'une mémoire cache.gif|centre|vignette|upright=2|Principe d'une mémoire cache.]] La mémoire cache est invisible pour le programmeur, qui ne peut pas déceler celles-ci dans l'assembleur. Les accès mémoire se font de la même manière avec ou sans le cache. La raison à cela est que le cache intercepte les accès mémoire et y répond s'il en a la capacité. Par exemple, si le cache intercepte une lecture à une adresse et que le contenu de cette adresse est dans le cache, le cache va outrepasser la mémoire RAM et la donnée sera envoyée par le cache au lieu d'être lue en RAM. par contre, si un accès se fait à une adresse pour laquelle le cache n'a pas la donnée, alors l'accès mémoire sera effectué par la RAM de la même manière que si le cache n'était pas là. [[File:Accès au cache.png|centre|vignette|upright=2|Accès au cache]] ===Les succès et défauts de caches=== Tout accès mémoire est intercepté par le cache, qui vérifie si la donnée demandée est présente ou non dans le cache. Si la donnée voulue est présente dans le cache, on a un '''succès de cache''' (''cache hit'') et on accède à la donnée depuis le cache. Sinon, c'est un '''défaut de cache''' (''cache miss'') et on est obligé d’accéder à la RAM. Les défauts de cache peuvent avoir plusieurs origines. Tout ce qu'il faut savoir est que lorsque le processeur accède à une donnée ou une instruction pour la première fois, il la place dans la mémoire cache car elle a de bonnes chances d'être réutilisée prochainement. La raison à cela est qu'un programme a tendance à réutiliser les instructions et données qui ont été accédées dans le passé : c'est le ''principe de localité temporelle''. Bien évidement, cela dépend du programme, de la façon dont celui-ci est programmé et accède à ses données et du traitement qu'il fait, mais c'est souvent vrai en général. La première cause des défauts de cache est liée à la taille du cache. À force de charger des données/instructions dans le cache, le cache fini par être trop petit pour conserver les anciennes données. Le cache doit bien finir par faire de la place en supprimant les anciennes données, qui ont peu de chances d'être réutilisées. Ces anciennes données éliminées du cache peuvent cependant être accédées plus tard. Tout prochain accès à cette donnée mènera à un cache miss. C'est ce qu'on appelle un ''Capacity Cache Miss'', ou encore '''défaut de capacité'''. Les seules solutions pour éviter cela consistent à augmenter la taille du cache ou à optimiser le programme exécuté (voir plus bas). Une autre raison pour un défaut est donc la suivante. Lorsqu'on exécute à une instruction ou qu'on accède à donnée pour la première fois, celle-ci n'a pas encore été chargée dans le cache. Le défaut de cache est inévitable : ce genre de cache miss s'appelle un ''Cold Miss'', ou encore un '''défaut à froid'''. De tels défauts sont presque impossibles à éliminer, sauf à utiliser des techniques de préchargement qui chargent à l'avance des données potentiellement utiles. Ces méthodes de préchargement se basent sur le principe de localité spatiale, à savoir le fait que les programmes ont tendance à accéder à des données proches en mémoire. Pour donner un exemple, les instructions d'un programme sont placées en mémoire dans l’ordre dans lequel on les exécute : la prochaine instruction à exécuter est souvent placée juste après l'instruction en cours (sauf avec les branchements). Quand on accède à une donnée ou une instruction, le cache peut précharger les données adjacentes pour en profiter. Nous parlerons de ces techniques de préchargement dans un chapitre dédié, vers la fin du cours. ===Le fonctionnement du cache, vu du processeur=== Vu du processeur, le cache prend en entrée toutes les informations nécessaires pour effectuer un accès mémoire : des signaux de commande, une adresse et la donnée à écrire si besoin. Tout cela est passé en entrée du cache, celui-ci répondant aux accès mémoire via divers bits de contrôles, que le processeur peut lire à souhait. Le cache fournit aussi la donnée à lire, pour les lectures, sur une sortie, connectée directement au bus mémoire/processeur. Globalement, le cache a une capacité limitée, mais il prend en entrée des adresses complètes. Par exemple, sur un processeur 64 bits, le cache prend en entrée des adresses de 64 bits (sauf si optimisations), même si le cache en question ne fait que quelques mébioctets. Les caches sont souvent des mémoires multiports, surtout sur les processeurs récents. Les caches simple port sont rares, mêmes s'ils existent et ont existé par le passé. les caches double port sont eux plus fréquents, et ont généralement un port d'écriture séparé du port de lecture. Mais les caches récents ont plusieurs ports de lecture/écriture et sont capables de gérer plusieurs accès mémoire simultanés. Les données présentes dans le cache sont (pré)chargées depuis la mémoire, ce qui fait que toute donnée dans le cache est la copie d'une donnée en mémoire RAM. Le cache doit faire la correspondance entre une donnée du cache et l'adresse mémoire correspondante. Du point de vue du fonctionnement, on peut voir le cache comme une sorte de table de correspondance, qui mémorise des données, chacune étant associée à son adresse mémoire. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Cela vaut du point de vue du processeur, le fonctionnement interne du cache étant quelque peu différent selon le cache. Il existe des caches dont le fonctionnement interne est bien celui d'une table de correspondance matérielle, d'autres qui sont beaucoup plus optimisés. [[File:Fonctionnement d'une mémoire associative à correspondance.png|centre|vignette|upright=2|Fonctionnement simplifié d'une mémoire cache : les adresses sont dans la colonne de gauche, les données sont dans la colonne de droite. On voit qu'on envoie l'adresse au cache, que celui-ci répond en renvoyant la donnée associée.]] ==La performance des mémoires caches== L'analyse de la performance des mémoires caches est plus riche pour celle des autres mémoires. Sa performance dépend de beaucoup de paramètres, mais on peut cependant citer les principaux. Les deux premiers sont tout bonnement sa latence et son débit, comme pour n'importe quelle autre mémoire. La latence est plus importante que son débit, car le processeur est généralement plus rapide que le cache et qu'il n'aime pas attendre. Mais le critère le plus important pour un cache est sa capacité à empêcher des accès mémoire, son efficacité. Plus les accès mémoire sont servis par le cache au lieu de la RAM, meilleures seront les performances. Pour résumer, la performance d'un cache est surtout caractérisée par deux métriques : le taux de défaut, qui correspond à l’efficacité du cache, et la latence du cache. ===Le taux de succès/défaut=== Le '''taux de succès''' (hit ratio) est un premier indicateur des performances du cache, mais un indicateur assez imparfait. C'est le pourcentage d'accès mémoire qui ne déclenchent pas de défaut de cache. Plus il est élevé, plus le processeur accède au cache à la place de la RAM et plus le cache est efficace. Certains chercheurs préfèrent utiliser le '''taux de défauts''', à savoir le pourcentage d'accès mémoire qui entraînent un défaut de cache. Plus il est bas, meilleures sont les performances. Le taux de défaut est relié au taux de succès par l'équation <math>T_\text{succes} = 1 - T_\text{defaut}</math>. Par définition, il est égal à : : <math>\text{Taux de défauts de cache} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d’accès mémoires}}</math> Plutôt que de comparer le nombre de défauts/succès de cache au nombre d'accès mémoire, il est aussi possible de diviser le nombre de défauts par le nombre total d'instructions. On obtient alors le '''taux de défauts/succès par instruction''', une autre métrique utile. Par définition, elle est égale à : : <math>\text{Taux de défauts par instruction} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d'instructions}} = \text{Taux de défauts de cache} \times \frac{\text{Nombre d’accès mémoires}}{\text{Nombre d'instructions}}</math> Si certains défauts de cache sont inévitables quel que soit le cache, comme les défauts à froids, mentionnés plus haut, d'autres défauts peuvent être évités en augmentant la capacité du cache. C'est le cas des défauts de capacité qui sont causés par un accès à une donnée qui a été éliminée du cache faute de place. Plus le cache est gros, moins il a de chances d'être rempli, moins il doit rapatrier de données, plus son taux de succès augmente. Mais nous reviendrons sur le lien entre taille du cache et taux de défaut plus bas. Le taux de succès ne dépend pas que du cache, mais aussi de la conception des programmes exécutés. Une bonne utilisation du cache (ainsi que de la mémoire virtuelle) repose sur le programmeur qui doit prendre en compte les principes de localités dès la conception de ses programmes. Par exemple, un programmeur peut parfaitement tenir compte du cache au niveau de son algorithme : on peut citer l'existence des algorithmes ''cache oblivious'', qui sont conçus pour être optimaux quelle que soit la taille du cache. Le programmeur peut aussi choisir ses structures de données de manière à améliorer la localité. Par exemple, un tableau est une structure de donnée respectant le principe de localité spatiale, tandis qu'une liste chaînée ou un arbre n'en sont pas (bien qu'on puisse les implémenter de façon à limiter la casse). D'autres optimisations sont parfois possibles : par exemple, le sens de parcours d'un tableau multidimensionnel peut faire une grosse différence. Cela permet des gains très intéressants pouvant se mesurer avec des nombres à deux ou trois chiffres. Je vous recommande, si vous êtes programmeur, de vous renseigner le plus possible sur les optimisations de code ou algorithmiques qui concernent le cache : il vous suffira de chercher sur Google. Il y a une citation qui résume bien cela, prononcée par un certain Terje Mathisen. Si vous ne le connaissez pas, cet homme est un vieux programmeur (du temps durant lequel on codait encore en assembleur), grand gourou de l’optimisation, qui a notamment travaillé sur le moteur de Quake 3 Arena. {{BlocCitation|Almost all programming can be viewed as an exercise in caching.|auteur=Terje Mathisen}} ===La latence moyenne d'un cache=== Le temps mis pour lire ou écrire une donnée varie en présence d'un cache. Certaines lectures/écritures vont atterrir directement dans le cache (succès) tandis que d'autres devront aller chercher leur contenu en mémoire RAM (défaut de cache). Dans tous les cas, qu'il y ait défaut ou non, le cache sera consulté et mettra un certain temps à répondre, égal au temps de latence du cache. Tous les accès mémoires auront donc une durée au moins égale au temps de latence du cache, qui sera notée <math>T_c</math>. En cas de succès, le cache aura effectué la lecture ou l'écriture, et aucune action supplémentaire n'est requise. Ce qui n'est pas le cas en cas de défaut : le processeur devra aller lire/écrire la donnée en RAM, ce qui prend un temps supplémentaire égal au temps de latence de la mémoire RAM. Un défaut ajoute donc un temps, une pénalité, à l'accès mémoire. Dans ce qui suivra, le temps d'accès à la RAM sera noté <math>T_m</math>. Fort de ces informations, nous pouvons calculer le temps de latence moyen d'un accès mémoire, qui est la somme du temps d'accès au cache (pour tous les accès mémoire), multiplié par le temps lié aux défauts. On a alors : : <math>T = T_c + \text{Taux de défaut} \times T_m</math> On voit que plus le taux de succès est élevé, plus le temps de latence moyen sera bas, et inversement. Ce qui explique l'influence du taux de succès sur les performances du cache, influence assez importante sur les processeurs actuels. De nos jours, le temps que passe le processeur dans les défauts de cache devient de plus en plus un problème au fil du temps, et gérer correctement le cache est une nécessité, particulièrement sur les processeurs multi-cœurs. Il faut dire que la différence de vitesse entre processeur et mémoire est tellement importante que les défauts de cache sont très lents : alors qu'un succès de cache va prendre entre 1 et 5 cycles d'horloge, un cache miss fera plus dans les 400-1000 cycles d'horloge. Tout ce temps sera du temps de perdu que le processeur aura du mal à mitiger. Autant dire que réduire les défauts de cache est beaucoup plus efficace que d'optimiser les calculs effectués par le processeur (erreur courante chez de nombreux programmeurs, notamment débutants). ===L'impact de la taille du cache sur le taux de défaut et la latence=== Il y a un lien entre taille du cache, taux de défaut, débit binaire et latence moyenne. Globalement, plus un cache est gros, plus il est lent. Simple application de la notion de hiérarchie mémoire vue il y a quelques chapitres. Les raisons à cela sont nombreuses, mais nous ne pouvons pas les aborder ici, car il faudrait que nous sachions comment fonctionne un cache et ce qu'il y a à l'intérieur, ce qui sera vu dans la suite du chapitre. Toujours est-il que la latence moyenne d'un cache assez gros est assez importante. De même, le débit binaire d'un cache diminue avec sa taille, mais dans une moindre mesure. Les petits caches ont donc un gros débit binaire et une faible latence, alors que c'est l'inverse pour les gros caches. Une grande capacité de cache améliore le taux de succès, mais cela se fait au détriment de son temps de latence et de son débit, ce qui fait qu'il y a un compromis assez difficile à trouver entre taille du cache, latence et débit. Il peut arriver qu'augmenter la taille du cache augmente son temps d'accès au point d’entraîner une baisse de performance. Par exemple, les processeurs Nehalem d'Intel ont vus leurs performances dans certains jeux vidéos baisser de 2 à 3 %, malgré de nombreuses améliorations architecturales, parce que la latence du cache L1 avait augmentée de 2 cycles d'horloge. Pour avoir une petite idée du compromis à faire, regardons la relation entre taille du cache et taux de défaut. Il existe une relation approximative entre ces deux variables, appelée la '''loi de puissance des défauts de cache'''. Elle donne le nombre total de défaut de cache en fonction de la taille du cache et de deux autres paramètres. Voici cette loi : : <math>\text{Taux de défauts de cache} \approx K \times \text{Taille du cache}^{- \alpha }</math>, avec <math>K</math> et <math>\alpha</math> deux coefficients qui dépendent du programme exécuté. Le coefficient <math>\alpha</math> est généralement compris entre 0.3 et 0.7, guère plus, et varie suivant le programme exécuté. Précisons que cette loi ne marche que si le cache est assez petit par rapport aux données à utiliser. Pour un cache assez gros et des données très petites, la relation précédente est mise en défaut. Pour s'en rendre compte, il suffit d'étudier le cas extrême où toutes les données nécessaires tiennent dans le cache. Dans ce cas, il n'y a qu'un nombre fixe de défauts de cache : autant qu'il faut charger de données dans le cache. Le nombre de défauts de cache observé dans cette situation n'est autre que le coefficient <math>K</math> de la situation précédente, mais il n'y a aucune dépendance entre taux de défaut et taille du cache. L'origine de cette relation s'explique quand on regarde combien de fois chaque donnée est réutilisée lors de l’exécution d'un programme. La plupart des données finissent par être ré-accédées à un moment ou un autre et il se passe un certain temps entre deux accès à une même donnée. Sur la plupart des programmes, les observations montrent que beaucoup de réutilisations de données se font après un temps très court et qu'inversement, peu de ré-accès se font après un temps inter-accès long. Si on compte le nombre de réutilisation qui ont un temps inter-accès bien précis, on retrouve une loi de puissance identique à celle vue précédemment : : <math>\text{Nombre de réaccès avec un temps inter-accès égal à t} \approx K \times t^{- \beta}</math>, avec t le temps moyen entre deux réutilisations. Le coefficient <math>\beta</math> est ici compris entre 1.7 et 1.3. De manière générale, les coefficients <math>\alpha</math> et <math>\beta</math> sont reliés par la relation <math>\alpha = 1 - \beta</math>, ce qui montre qu'il y a un lien entre les deux relations. Précisons cependant que la loi de puissance précédente ne vaut pas pour tous les programmes informatiques, mais seulement pour la plupart d’entre eux. Il n'est pas rare de trouver quelques programmes pour lesquels les accès aux données sont relativement prédictibles et où une bonne optimisation du code fait que la loi de puissance précédente n'est pas valide. La loi de puissance des défauts de cache peut se démontrer à partir de la relation précédente, sous certaines hypothèses. Si un suppose que le cache est assez petit par rapport aux données, alors les deux relations sont équivalentes. L'idée qui se cache derrière la démonstration est que si le temps entre deux accès à une donnée est trop long, alors la donnée accédée aura plus de chance d'être rapatriée en RAM, ce qui cause un défaut de cache. La chance de rapatriement dépend de la taille du cache, un cache plus gros peut conserver plus de données et a donc un temps avant rapatriement plus long. ==Les lignes de cache et leurs tags== Du point de vue du processeur, les lectures et écritures se font mot mémoire par mot mémoire. Un processeur avec des entiers de 64 bits recoit des données de 64 bits de la part du cache, et y écrit des mots de 64 bits. Mais quand on regarde comment sont stockées les données à l'intérieur du cache, les choses sont différentes. ===Les lignes de cache=== Les données sont mémorisées dans le cache par blocs de plusieurs bytes, d'environ 64 à 256 octets chacun, qui portent le nom de '''lignes de cache'''. Les lignes de cache sont l'unité de stockage que l'on trouve à l'intérieur du cache, mais elles servent aussi d'unité de transaction avec la mémoire RAM. Sur les caches actuels, on transfère les données entre le cache et la RAM ligne de cache par ligne de cache, dans la limite de la taille du bus mémoire. Mais d'autres caches plus anciens permettaient de faire des transferts plus fins. C’est-à-dire qu'on pouvait mettre à jour quelques octets dans une ligne de cache sans avoir à la recopier intégralement depuis ou dans la mémoire RAM. En théorie, on pourrait imaginer des caches où les données sont stockées différemment, où l'unité serait le mot mémoire, par exemple. Par exemple, sur un processeur 64 bits, on aurait une ligne de cache de 64 bits. Cela aurait l'avantage de la simplicité : les transferts entre le processeur et la mémoire serait de même taille, l'intérieur du cache ressemblerait à son interface montrée au processeur. Mais cela aurait quelques défauts qui sont compensés par l'organisation en lignes de cache de grande taille. Le premier avantage des lignes de cache est lié à la localité spatiale, la tendance qu'on les programmes à accéder à des données proches les unes des autres. Des accès mémoires consécutifs ont tendance à se faire à des adresses proches, qui ont de bonnes chances d'être dans la même ligne de cache. Et des accès consécutifs à une même ligne de cache sont plus rapides que des accès à deux lignes distinctes. Une autre raison est tout simplement que cela simplifie considérablement la circuiterie du cache. Pour une capacité identique, il vaut mieux avoir peu de lignes de cache assez grosses, que beaucoup de petites lignes de cache. La raison est que les circuits du cache, comme le décodeur, l'encodeur et autres, ont moins de sorties et sont donc plus simples. ===L'alignement des lignes de cache=== Les lignes de cache sont des blocs de plusieurs dizaines à centaines de bytes, dont la taille est presque toujours une puissance de deux. De plus, les lignes de cache sont alignées en mémoire. Nous avions déjà abordé la notion d'alignement mémoire dans un chapitre précédent, mais le concept d'alignement des lignes de cache est quelque peu différent. Quand nous avions parlé d'alignement auparavant, il s'agissait de l'alignement des données manipulées par le processeur, qui faisait partie du jeu d'instruction du processeur. Ici, nous parlons d'un alignement totalement différent, invisible pour le programmeur, sans lien avec le jeu d’instruction. Voyons de quoi il retourne. Concrètement, cela veut dire que du point de vue du cache, la RAM est découpée en blocs qui font la même taille qu'une ligne de cache, aux positions prédéterminées, sans recouvrement entre les blocs. Par exemple, pour un cache dont les lignes de cache font 256 octets, le premier bloc est à l'adresse 0, le second est 256 octets plus loin, c'est à dire à l'adresse 256, le troisième à l'adresse 512, la quatrième à l'adresse 768, etc. Une ligne de cache de 256 octets contiendra une donnée provenant d'un bloc de RAM de 256 octets, dont l'adresse est systématiquement un multiple de 256. Il n'est pas possible qu'une ligne de cache contienne un bloc de 256 octets dont l'adresse du premier octet serait l'adresse 64, ou l'adresse 32, par exemple. En clair, les adresses de ces blocs sont des multiples de la taille de la ligne de cache, de la taille des blocs. Cela rappelle les contraintes d'alignement vues dans le chapitre "Le modèle mémoire : alignement et boutisme", mais appliquées aux lignes de cache. L'alignement des lignes de cache a des conséquences pratiques pour la conception des caches. Notons qu'il est en théorie possible d'avoir des caches dont les lignes de cache ne sont pas alignées, mais cela poserait des problèmes majeurs. Il serait en effet possible qu'une donnée soit présente dans deux lignes de cache à la fois. Par exemple, prenons le cas où une ligne de cache de 256 commence à l'adresse 64 et une autre ligne de cache commence à l'adresse 0. L'adresse 128 serait dans les deux lignes de cache ! Et cela poserait des problèmes lors des lectures, mais encore plus lors des écritures. C'est pour éviter ce genre de problèmes que les lignes de cache sont alignées avec la mémoire RAM dans tous les caches existants. L'alignement des lignes de cache est une chose que les programmeurs doivent parfois prendre en compte quand ils écrivent du code ultra-optimisé, destiné à des programmes demandant des performances extrêmes. Il arrive que les contraintes d'alignement posent des problèmes. Nous avions vu dans le chapitre sur le boutisme et l'alignement qu'il valait mieux gérer l'alignement des variables des structures de données, pour éviter les accès non-alignés avec le bus mémoire. La même chose est possible, mais pour l'alignement avec des lignes de cache. Typiquement, l'idéal est que, pour une structure de donnée, on puisse en mettre un nombre entier dans une ligne de cache. Ou alors, si la structure est vraiment grande, que celle-ci occupe un nombre entier de lignes de cache. Si ce n'est pas le cas, il y a un risque d'accès non-alignés, c'est à dire qu'une structure se retrouve à cheval sur deux lignes de cache, avec les défauts que cela implique. ===Le tag d'une ligne de cache=== Plus haut, nous avions dit que le cache mémorise, pour chaque ligne de cache, l'adresse RAM associée. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Mais du fait de l'organisation du cache en lignes de cache de grande taille, qui sont de plus alignées en mémoire, il faut nuancer cette affirmation. Le cache ne mémorise pas la totalité de l'adresse, ce qui serait inutile. L'alignement des lignes de cache en RAM fait que les bits de poids faible de l'adresse ne sont pas à prendre en compte pour l'association adresse-ligne de cache. Dans ces conditions, on mémorise seulement la partie utile de l'adresse mémoire correspondante, qui forme ce qu'on appelle le '''tag'''. Le reste de l'adresse indique quelle est la position de la donnée dans la ligne de cache. Par exemple, prenons le cas où le processeur gère des nombres entiers de 64 bits (8 octets) et des lignes de cache de 128 octets : chaque ligne de cache contient donc 16 entiers. Si le processeur veut lire ou écrire un entier bien précis, il doit préciser sa place dans la ligne de cache. Et ce sont les bits de l'adresse mémoire non-inclus dans le cache qui permettent de faire ça. En clair, une adresse mémoire à lire/écrire est interprété par le cache comme la concaténation d'un tag et de la position de la donnée dans la ligne de cache correspondante. [[File:Adressage d'un cache totalement associatif.png|centre|vignette|upright=2|Adressage d'un cache totalement associatif]] Le cache est donc une grande table de correspondance entre tags et lignes de cache. Lors d'un accès mémoire, le cache extrait le tag de l'adresse à lire ou écrire, et le compare avec les tags de chaque ligne de cache. Si une ligne contient ce tag, alors c'est que cette ligne correspond à l'adresse, et c'est un défaut de cache sinon. Lors d'un succès de cache, la ligne de cache est lue depuis le cache et envoyée à un multiplexeur qui sélectionne la donnée à lire dans la ligne de cache. Le fonctionnement est similaire pour une écriture : la donnée à écrire passe dans un démultiplexeur, qui envoie la donnée au bon endroit dans la ligne de cache sélectionnée. [[File:Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.png|centre|vignette|upright=2|Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.]] ===Le contenu d'une ligne de cache=== Dans ce qui va suivre, nous allons considérer que chaque ligne de cache mémorise son tag, les données de la ligne de cache proprement dit, et quelques bits de contrôle annexes qui varient suivant le cache considéré. [[File:Tag d'une ligne de cache.png|centre|vignette|upright=2|Tag d'une ligne de cache.]] Les caches modernes incluent de nombreux bits de contrôle, mais deux d'entre eux sont communs à presque tous les caches modernes : le bit ''Dirty'' et le bit ''Valid''. Le '''bit ''Valid''''' indique si la ligne de cache contient des données valides ou non. Si le bit ''Valid'' est à 0, la ligne de cache est en état valide, à savoir qu'elle contient des données et n'est pas vide. Par contre, si ce bit est à 1, la ligne de cache est invalide et son contenu ne peut pas être lu ou écrit. L'utilité de ce bit est qu'il permet d'effacer une ligne de cache très rapidement : il suffit de mettre ce bit à 0. Il existe des situations où le cache doit être effacé, on dit alors qu'il est invalidé. Une section de ce chapitre sera dédié à l'invalidation du cache. Le '''bit ''Dirty''''' indique qu'une ligne de cache a été modifiée. Par modifiée, on veut dire que le processeur a écrit dedans, qu'il a modifié la ligne de cache. Mais attention : si la donnée a été modifiée dans le cache, la modification n'est pas forcément propagée en mémoire RAM. Le bit ''dirty'' indique si c'est le cas, si l'écriture a été propagée en mémoire RAM. Il précise que la ligne de cache contient des données modifiées, alors que la RAM a des données initiales non-modifiées. Une ligne de cache avec un bit ''dirty'' à 1 est dite ''dirty'', par métonymie. Nous verrons cela en détail dans la section sur les caches ''write-back'' et ''write-through''. Les caches modernes ajoutent des '''bits de détection/correction d'erreur''' dans les bits de contrôle. Pour rappel, les codes de détection/correction d'erreur permettent de se prémunir contre des erreurs matérielles, qui corrompent les données stockées dans une mémoire, ici une mémoire cache. Ils ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Nous reviendrons dessus dans une section ultérieur de ce chapitre. Sur certains caches assez anciens, on pouvait transférer les lignes de caches morceaux par morceaux. Ces caches avaient des lignes de cache divisées en sous-secteurs, ces sous-secteurs étant des morceaux de ligne de cache qu'on pouvait charger indépendamment les uns des autres (mais qui sont consécutifs en RAM). Chaque secteur avait ses propres bits de contrôle, mais le tag était commun à tous les secteurs. [[File:Cache à secteurs.png|centre|vignette|upright=2.5|Cache à secteurs.]] : Dans ce qui va suivre, le terme "ligne de cache" désignera soit un bloc de données copiées depuis la RAM d'une taille de 64/128/256/... octets, soit la concaténation de ces données avec le tag et des bits de contrôle. Les deux définitions ne sont pas équivalentes, mais l'usage a entériné cet abus de langage. Et il faut avouer que cela rend les explications du chapitre plus simples. ==Les instructions de contrôle du cache== Plus haut, nous avions dit que le cache est totalement transparent du point de vue du programmeur. Le cache contient des copies de données en RAM, le programmeur n'a rien à faire pour utiliser le cache correctement. Mais la réalité est que pour des raisons diverses, des processeurs incorporent des '''instructions de contrôle du cache'''. Il s'agit d’instructions qui agissent sur le contenu du cache. Elles existent pour des raisons diverses qu'on détaillera plus bas, mais il s'agit globalement d'une question de performances ou de nécessité pour le système d'exploitation. ===Les instructions de préchargement=== La première instruction de contrôle du cache est une '''instruction de préchargement''', qui demande à charger un bloc de données dans le cache. Elle prend en opérande une adresse mémoire, et le contenu de cette adresse est chargé dans une ligne de cache. Bien sûr, des contraintes d'alignement sont à prendre en compte : on charge un bloc de la même taille qu'une ligne de cache, aligné en mémoire sur la taille du bloc, qui contient l'adresse. L'instruction de préchargement n'est utile que si l'instruction est exécutée bien avant que la donnée ne soit utilisée/lue/écrite. Cela permet de charger une donnée dans le cache à l'avance, d'où le nom de préchargement donné à cette technique. Mais les processeurs modernes gérent des techniques de préchargement automatique, qui ne requièrent pas d'instructions de préchargement. Le préchargement automatique et les instructions de préchargement sont deux solutions complémentaires, mais qui peuvent se marcher sur les pieds. Nous en reparlerons dans le prochain chapitre, qui sera dédié au préchargement automatique. Il faut noter que les instructions de préchargement peuvent être ignorées par le processeur. Sous certaines conditions, le processeur peut décider que l'instruction de préchargement ne sera pas exécutée. Par exemple, il ne va pas précharger une donnée déjà présente dans le cache. Ou encore, si le bus mémoire est occupé, il ne va pas exécuter le préchargement, par manque de ressources matérielles. ===Les instructions d'invalidation et de ''flush''=== Les instructions ''flush'' regroupent deux types d'instructions qui sont souvent utilisées en même temps. Il s'agit des instructions d'invalidation et de nettoyage (''clean''). Les deux termes proviennent de la terminologie ARM, il n'y a pas de terminologie standardisé pour les noms de ces instructions. Dans les grandes lignes, elles permettent de vider le cache, à savoir de rapatrier son contenu en RAM et de réinitialiser le cache à zéro. Elles sont utilisées par le système d'exploitation lors des commutations de contexte, à savoir quand on passe d'un programme à un autre. Elles sont aussi utilisées lors des appels systèmes et routines d'interruption/exception. L'idée est de vider le cache avant d'exécuter un nouveau programme ou une nouvelle routine. Le nouveau programme aura accès à un cache tout propre, les données de l'ancien programme auront été retirée du cache. Les '''instructions ''clean''''' recopient le contenu de la ligne de cache en RAM. Elles forcent la recopie immédiatement de la ligne de cache en mémoire RAM. Pour faire leur travail, elle vérifient si la ligne de cache a été modifiée, avant de la recopier en RAM. Et pour cela, ils vérifient le bit de contrôle ''dirty'', qui est mis à 1 après une première écriture. Si ce bit est à 0, alors pas besoin de recopier la ligne de cache : elle n'a pas été modifiée, la RAM a déjà la bonne copie. Mais s'il est à 1, le cache et la RAM n'ont pas le même contenu, la recopie s'exécute. Les '''instructions d'invalidation''' permettent d'invalider une ligne de cache, à savoir d'effacer son contenu. Nous verrons à quoi servent ces instructions dans la section sur les changement de processus. Invalider une ligne de cache est une opération optimisée : le cache n'est en réalité pas réellement effacé. À la place, le bit ''Valid'' de chaque ligne de cache est juste mis à 0. Il faut noter que l'invalidation efface les lignes de cache sans se préoccuper de leur contenu. Elle se moque qu'une ligne de cache contienne une donnée modifiée, ''dirty'' ou quoique ce soit : la ligne de cache est effacée, point. Il est possible d'invalider une ligne de cache en fournissant une adresse mémoire, mais il est aussi possible d'invalider le cache tout entier. Le choix entre les deux dépend du mode d'adressage de l'instruction d'invalidation. Parfois, il existe une instruction séparée pour invalider tout le cache, et une autre pour invalider une ligne de cache bien précise. Des instructions séparées sont parfois disponibles pour invalider les caches de données et d'instructions, parfois aussi la TLB (un cache qu'on verra dans quelques chapitres). Il est possible de n'invalider que le cache L1, voire le cache L2. Il faut noter que l'invalidation efface tout le cache, mais ne se préoccupe pas de vérifier si les données ont été modifiées dans le cache. Pour certains caches, comme le cache d'instruction, ce n'est pas un problème, vu qu'il est en "lecture seule". Mais pour les caches de données, les données modifiées sont perdues en cas d'invalidation. Heureusement, il existe des instructions d'invalidation qui fusionnent une instruction ''clean'' et une instruction d'invalidation. Il s'agit d''''instructions d'invalidation spéciales'''. ===Les instructions d'optimisation : instructions non-temporelles et écritures optimisées=== Les '''instructions mémoire non-temporelles''' contournent complètement le cache. Par exemple, une lecture peut lire une donnée, mais celle-ci ne sera pas chargée dans le cache, elle passe directement de la RAM vers les registres. Une section entière de ce chapitre sera dédiée au contournement du cache, à savoir aux situations où les accès mémoire doivent passer directement du processeur à la RAM sans passer par le cache. D'autres instructions assez rares incorporent des indications pour le cache. Par exemple, l'instruction ''load last'' des processeurs POWER PC implique que la donnée ne sera utilisée qu'une seule fois. Elle est donc chargée dans le cache, mais la ligne de cache est configurée de manière à être remplacée très rapidement, typiquement avec une valeur de LRU/LFU adéquate. La donnée est bien chargée dans le cache, au cas où elle doive être relue suite à une mauvaise prédiction de branchement ou autre, chose qu'une lecture non-temporelle (qui contourne le cache) ne fait pas. Des indications de ce type sont appelées des '''''cache hint'''''. L''''instruction ''flush''''' permet de préciser qu'une ligne de cache contient une donnée inutile, qui ne sera pas réutilisée par le programme. Pas besoin de la conserver dans le cache, elle peut laisser sa place à des données plus utiles. Or, sans indication, les algorithmes de remplacement d'une ligne de cache risquent de conserver cette donnée trop longtemps, ce qui entraine une certaine pollution du cache par des données inutiles. Une autre instruction est elle beaucoup plus importante : celle de '''pré-allocation sur écriture'''. Elle sert dans le cas où une ligne de cache est complétement écrite. Par exemple, imaginons qu'on veuille écrire dans une portion de mémoire. Si celle-ci n'est pas dans le cache, le processeur va charger une ligne de cache complète depuis la RAM, écrire dans la ligne de cache, puis recopier la ligne de cache modifiée en mémoire RAM. Une écriture en RAM demande donc de faire une lecture et une écriture. Mais les instructions de pré-allocation sur écriture permettent de prévenir qu'une ligne de cache sera intégralement écrite, et qu'il n'y a donc pas besoin de lire celle-ci depuis la RAM. Notons que l'instruction d'écriture qui suit n'est pas une écriture non-temporelle, vu que les données sont écrites dans la ligne de cache, qui est ensuite envoyée en mémoire RAM dès que nécessaire. De plus, les données écrites peuvent ensuite être relue depuis le cache si nécessaire. Enfin, certains processeurs MIPS incorporent une instruction pour modifier le tag d'une ligne de cache. Elles servent à optimiser les copies mémoire, à savoir quand on copie un bloc de données d'un endroit à un autre. L'idée est de charger le bloc de données dans le cache avec une instruction LOAD/PREFETCH, de modifier le tag pour qu'il pointe vers l'adresse à écrire, et de laisser faire le cache pour que l'écriture se fasse en RAM. Mais les contraintes pour utiliser cette instruction sont assez drastiques : les données doivent être alignées sur la taille d'une ligne de cache, le bloc de départ et d'arrivée (l'original versus la copie) ne doivent pas se recouvrir, etc. ==L'associativité des caches et leur adressage implicite== Lorsqu'on souhaite accéder au cache, il faut trouver quelle est la ligne de cache dont le tag correspond à l'adresse demandée. On peut classifier les caches selon leur stratégie de recherche de la ligne correspondante en trois types de caches : totalement associatifs, directement adressés (''direct mapped'') et associatifs par voie. ===Les caches totalement associatifs=== Avec les caches totalement associatifs, toute donnée chargée depuis la mémoire peut être placée dans n'importe quelle ligne de cache, sans aucune restriction. Ces caches ont un taux de succès très élevé, quand on les compare aux autres caches. [[File:Cache totalement associatif.png|centre|vignette|upright=2|Cache totalement associatif.]] Concevoir un cache totalement associatif peut se faire de deux grandes manières différentes. La première consiste tout simplement à combiner une mémoire associative avec une mémoire RAM, en ajoutant éventuellement quelques circuits annexes. La mémoire associative mémorise les tags, alors que la mémoire RAM mémorise les données de la ligne de cache, éventuellement avec quelques bits de contrôle. La ligne de cache est stockée à une adresse A dans la mémoire RAM et son tag est stocké à la même adresse, mais dans la mémoire CAM. Ce faisant, quand on envoie le tag à la mémoire CAM, elle renvoie l'adresse de la ligne de cache dans la mémoire RAM. Cette adresse est alors envoyée directement sur le bus d'adresse de la RAM, et la lecture est effectuée automatiquement. Il faut ajouter quelques circuits annexes pour garantir que les écritures se passent correctement dans les deux mémoires, mais rien de bien terrible. [[File:Cache fabriqué avec une mémoire associative et une RAM.png|centre|vignette|upright=3|Cache fabriqué avec une mémoire associative et une RAM]] Il est cependant possible d'optimiser un tel cache, en fusionnant la mémoire CAM et la mémoire RAM, afin d'éliminer des circuits redondants. Pour comprendre pourquoi, rappelons que les mémoires CAM sont composées d'un plan mémoire, d'un paquet de comparateurs et d'un encodeur. Quant à la mémoire RAM, elle est composée d'un décodeur connecté au plan mémoire. En mettant une CAM suivie d'une RAM, on a un encodeur dont l'entrée est envoyée à un décodeur. [[File:Cache totalement associatif naif.png|centre|vignette|upright=3|Cache totalement associatif naif]] Or, le décodeur réalise l'opération inverse de l'encodeur, ce qui fait que mettre les deux composants à la suite ne sert à rien. On peut donc retirer l'encodeur et le décodeur, et envoyer directement les résultats des comparateurs sur les entrées de commande du plan mémoire de la RAM. [[File:Cache totalement associatif optimisé.png|centre|vignette|upright=2|Cache totalement associatif optimisé]] Avec cette méthode, les circuits du cache ressemblent à ce qui illustré ci-dessous. Le tag est envoyé à chaque ligne de cache. Le tag envoyé est alors comparé avec le Tag contenu dans chaque ligne de cache, comme c'est le cas sur les mémoires associatives. Si une ligne de cache matche avec le tag envoyé en entrée, la ligne pour laquelle il y a eu une égalité est alors connectée sur les lignes de bit (''bitlines''). Cela est réalisé par un circuit commandé par le comparateur de la ligne de cache. Il ne reste plus qu'à sélectionner la portion de la ligne de cache qui nous intéresse, grâce à un paquet de multiplexeurs. Cela permet d'effectuer une lecture ou écriture, mais il faut aussi préciser si il y a eu un défaut de cache ou un succès. Un succès de cache a lieu quand au moins des comparaisons est positive, alors que c'est un défaut de cache sinon. En clair, détecter un succès de cache demande juste de connecter une porte OU à plusieurs entrées à tous les comparateurs. [[File:Organisation générale d'un cache totalement associatif.png|centre|vignette|upright=2|Organisation générale d'un cache totalement associatif.]] ===Les caches directement adressés=== Les caches directement adressés peuvent être vus comme un cache totalement associatif auquel on aurait ajouté des restrictions assez drastiques. Plus haut, on a vu qu'un cache totalement adressé est équivalent à la combinaison d'une CAM avec une RAM. La mémoire CAM prend en entrée un Tag et traduit celui-ci en une adresse qui commande la mémoire RAM interne au cache. Dans ce qui suit, l'adresse interne au cache sera appelé l''''indice''' pour éviter toute confusion. [[File:Cache hash table - 2.png|centre|vignette|upright=2|Fonctionnement interne du cache, expliquée sous forme abstraite, en utilisant la notion d'indice interne au cache.]] Les caches directement adressés cherchent à remplacer la mémoire CAM par un circuit combinatoire. Ce circuit traduit le Tag en indice, mais est beaucoup plus simple qu'une mémoire CAM. Mais qui dit circuit plus simple dit circuit plus limité. Un circuit combinatoire n'est pas aussi versatile que ce qui est permis avec une mémoire CAM. En conséquence, une restriction majeure apparait : toute adresse mémoire est associée dans une ligne de cache prédéfinie, toujours la même. L'association entre ligne de cache et adresse mémoire est faite par le circuit combinatoire, et ne peut pas changer. Les concepteurs de caches s'arrangent pour que des adresses consécutives en mémoire RAM occupent des lignes de cache consécutives, par souci de simplicité. Tout se passe comme suit la mémoire RAM était découpés en blocs de la même taille que le cache. La première adresse du bloc est associée à la première ligne de cache (celle d'indice 0), la seconde adresse est associée à la seconde adresse du_ bloc, et ainsi de suite. Le tout est illustré ci-dessous. [[File:Cache adressé directement.png|centre|vignette|upright=2|Cache adressé directement.]] Avec cette contrainte, le circuit de traduction de l'adresse en adresse mémoire pour la RAM interne au cache est drastiquement simplifié, et disparait même. Une partie de l'adresse mémoire sert à indiquer la position de la donnée dans le cache, le reste de l'adresse sert encode le tag et la position de la donnée dans le ligne de cache. [[File:Cache line.png|centre|vignette|upright=2|Adresse d'une ligne de cache sur un cache adressé directement.]] Un cache directement adressé est conçu avec une RAM, un comparateur, et un paquet de multiplexeurs. En général, la mémoire RAM stocke les lignes de caches complète. Il arrive que l'on utilise deux mémoires RAM : une pour les tags et une pour les données, mais cette technique augmente le nombre de circuits et de portes logiques nécessaires, ce qui réduit la capacité du cache. L'index à lire/écrire est envoyé sur l'entrée d'adresse de la RAM, la RAM réagit en mettant la ligne de cache sur sa sortie de donnée. Sur cette sortie, un comparateur compare le tag de la ligne de cache lue avec le tag de l'adresse à lire ou écrire. On saura alors si on doit faire face à un défaut de cache. Ensuite, un multiplexeur récupère la donnée à lire/écrire. [[File:Direct mapped cache - french.png|centre|vignette|upright=2|Cache directement adressé.]] L'accès à un cache directement adressé a l'avantage d'être très rapide vu qu'il suffit de vérifier une seule ligne de cache : celle prédéfinie. Mais ces caches ne sont cependant pas sans défauts. Vu que le cache est plus petit que la mémoire, certaines adresses mémoires se partagent la même ligne de cache. Si le processeur a besoin d’accéder fréquemment à ces adresses, chaque accès à une adresse supprimera l'autre du cache : tout accès à l'ancienne adresse se soldera par un défaut de cache. Ce genre de défauts de cache causés par le fait que deux adresses mémoires ne peuvent utiliser la même ligne de cache s'appelle un '''défaut par conflit''' (''conflict miss''). Les défauts par conflit n'existent pas sur les caches totalement associatifs. En conséquence, le taux de succès des caches directement adressés est assez faible comparé aux autres caches. [[File:Cache Block Basic Conflict.svg|centre|vignette|upright=1.5|Exemple de ''Conflict Miss''.]] ===Les caches associatifs par voie=== Les caches associatifs par voie sont un compromis entre les caches directement adressés et les caches totalement associatifs. Pour simplifier, ces caches sont composés de plusieurs caches directement adressés accessibles en parallèle, chaque cache/RAM étant appelé une '''voie'''. Avec ces caches, toute adresse mémoire en RAM est associée à une ligne de cache dans chaque voie. [[File:Cache associatif par voie.png|centre|vignette|upright=2|Cache associatif par voie.]] Le schéma ci-dessous compare un cache directement adressé et un cache associatif à deux voies. On voit que chaque adresse est associée à une ligne de cache bien précise avec un cache directement dressé, et à deux lignes de cache avec un cache associatif à deux voies. L'adresse sera associée à 4 lignes de cache sur un cache associatif à 4 voies, à 8 lignes pour un cache à 8 voies, etc. L'ensemble des lignes de cache associées à une adresse est appelé un '''ensemble'''. [[File:Cache Fill.svg|centre|vignette|upright=2|Comparaison entre un cache directement adressé et un cache associatif à deux voies.]] Sur ces caches, toute adresse est découpée en trois parties : un tag, un index, et un décalage, comme sur les caches directement adressés. Comme vous pouvez le voir, l'organisation est identique à celle d'un cache totalement associatif, à part que chaque ensemble tag-ligne de cache est remplacé par une mémoire RAM qui en contient plusieurs. [[File:Implémentation d'un cache associatif par voie.png|centre|vignette|upright=2|Implémentation d'un cache associatif par voie.]] Le risque de conflits d'accès au cache est donc réduit sur un cache associatif à plusieurs voies, et il est d'autant plus réduit que le cache a de voies. Par contre, leur conception interne fait qu'ils ont un temps d'accès légèrement élevé que les caches directement adressés. Les caches associatifs par voie ont donc un taux de succès et un temps d'accès intermédiaire, situé entre les caches directement adressés et totalement associatifs. Ils sont une sorte de compromis entre réduction des défaut par conflits d'accès au cache et temps d'accès, et complexité des circuits. ==Les optimisations des caches associatifs par voie== Les caches partiellement associatifs regroupent les caches associatifs par voie et directement adressés, ainsi que leurs variantes. En clair : tous les caches qui ne sont pas totalement associatifs. Ils peuvent être optimisés de nombreuses manières, que ce soit pour gagner en performance ou pour économiser de l’énergie. Dans cette section, nous allons voir quelles sont ces optimisations. ===Les caches pseudo-associatifs=== Les caches adressés par voie contiennent une mémoire SRAM par voie. En théorie, les voies sont accédées en parallèles, en même temps, afin de voir si l'on a un succès de cache ou un défaut. Les '''caches pseudo-associatifs''' sont identiques aux caches associatifs par voie, si ce n'est qu'ils vérifient chaque voie une par une. Ils ont été utilisés sur des processeurs commerciaux, un exemple étant l'IBM 370. Là encore, on perd en performance pour gagner en consommation d'énergie. Le temps d'accès dans le meilleur des cas est plus faible pour les caches pseudo-associatifs, mais le pire des cas teste tous les caches avant de tomber sur le bon. Les performances sont donc réduites. Mais la consommation énergétique est meilleure, vu qu'on ne vérifie pas forcément toutes les voies en parallèle. On teste la première voie, éventuellement la seconde, peut-être la troisième, etc. Mais dans le cas général, on ne teste qu'une partie des voies, pas toutes, ce qui donne un gain en termes d'énergie. L'implémentation de caches de ce genre demande que l'on parcoure les voies une par une, en commençant de la première jusqu'à la dernière. Pour cela, un simple compteur suffit. Suivant la valeur du compteur, la voie associée est activée puis accédée. Toute la complexité revient à ajouter un circuit qui prend la valeur du compteur, et active la voie associée, lance un accès mémoire dessus. Vu que les voies sont chacune des caches ''direct mapped'', il suffit pour cela de geler les entrées d'adresse, soit en les déconnectant, soit en utilisant du ''clock gating'' ou de l'évaluation gardée. Les détails d'implémentation, non-cités ici, varient selon le cache. ===La prédiction de voie=== Pour réduire le temps d'accès des caches pseudo-associatifs, certains chercheurs ont inventé la '''prédiction de voie''', qui consiste à faire des paris sur la prochaine voie accédée. L'idée est d'accéder à la voie qui contient la donnée voulue du premier coup, en lisant celle-ci en priorité. Dans son implémentation la plus simple, le cache reste un cache pseudo-associatif. Lors d'un accès au cache, les voies sont toutes parcoures une par une. Par contre, les voies ne sont donc pas parcourues de la première vers la dernière, mais dans un ordre différent. Cette technique permet de mettre en veille les voies sur lesquels le processeur n'a pas parié, ce qui permet de diminuer la consommation énergétique du processeur. C'est plus efficace que d'aller lire plusieurs données dans des voies différentes et de n'en garder qu'une. L'implémentation est assez simple : il suffit d'ajouter un circuit de prédiction de voie,relié au compteur de voie. Une amélioration de la technique fait fonctionner le cache comme un intermédiaire entre cache pseudo-associatif et associatif par voies. L'idée est de chercher la voie prédite en premier, puis de chercher dans toutes les voies en parallèle en cas de défaut de cache. Au lieu d'attendre que les comparaisons de tags donnent leur résultat, le processeur sélectionne automatiquement une voie et configure les multiplexeurs à l'avance. Si le processeur ne se trompe pas, le processeur accède à la donnée plus tôt que prévu. S'il se trompe, le processeur annule la lecture effectuée en avance et recommence en faisant un accès en parallèle aux autres voies. Le compromis entre performance et consommation d'énergie est alors différent. On économise de l'énergie par rapport à un cache associatif par voie, au prix d'une petite perte de performance (doublement des temps d'accès). Mais par rapport à un cache pseudo-associatif, l'économie d'énergie est bien moindre, au prix d'un gain en performance assez manifeste. Prédire quelle voie sera la bonne est assez simple. En vertu du principe de localité, les accès futurs ont des chances de tomber dans les voies les plus fréquemment utilisées ou dans celle plus récemment utilisée. Il suffit de retenir la voie la plus récemment accédée dans un registre, qui sera utilisée comme prédiction. Pour vérifier que la prédiction est correcte, il suffit de comparer le registre et le résultat obtenu après vérification des tags. Cependant, on peut complexifier l'implémentation pour prendre en compte l'adresse à lire/écrire, l'instruction à l'origine de l'accès mémoire ou tout autre paramètre utile. Par exemple, des instructions différentes ont tendance à aller chercher leurs données dans des ensembles différents et la voie à choisir n'est pas la même. Pour cela, il suffit d'utiliser un cache pour stocker la correspondance instruction - voie. Pour plus de simplicité, la mémoire cache des prédictions est parfois remplacée par une RAM, qui est adressée : * soit par le program counter de l'instruction à l'origine de l'accès (en réalité, seulement quelques bits de poids faible de l'adresse) ; * soit par l'adresse à accéder (là encore, quelques bits de poids faible) ; * soit (pour les modes d'adressage qui utilisent un registre de base et un décalage) par un XOR entre les bits de poids faible de l'adresse de base et le décalage ; * soit par autre chose. ===La mise en veille sélective des voies=== Les caches associatifs ont tendance à utiliser beaucoup d'énergie, même quand on n'y accède pas. Aussi, certains processeurs détectent quand le cache est peu utilisé et en profitent pour mettre en veille les voies inutilisées. Vous vous demandez certainement ce qui se passe quand une donnée à lire/écrire est dans une voie désactivée. La réponse est que le cache détecte cette situation, car elle déclenche un succès de cache. Les ''tags'' ne sont en effet pas désactivés, seules les données sont mises en veille. L'implémentation est plus simple sur les caches qui séparent les tags et les données dans deux RAM différentes. Cette optimisation marche surtout sur les gros caches, qui ont des chances d'avoir une portion significative d’inutilisée (pas assez de données pour les remplir), donc généralement les caches L3/L4. Par exemple, les processeurs d'Intel de microarchitecture Ivy Bridge disposent d'un cache de 8 mébioctets à 16 voies, qu'ils peuvent faire passer à 512 kibioctets si le besoin s'en fait sentir. Quand ces processeurs détectent une faible activité, ils mettent en veille 14 voies et n'en gardent que 2 d'actives. Évidemment, les 14 voies sont vidées avant d'être mises en veille, afin qu'une aucune donnée ne soit perdue. ===Les caches ''skew-associative''=== Vous aurez remarqué que dans une voie, les lignes sont accédées en adressage direct : les défauts par conflit sont possibles sur un cache associatif par voie. Pour éviter cela, certains chercheurs ont créé des '''caches ''skew associative''''' (ou associatifs à biais). Pour faire simple, les index des lignes de cache subissent un petit traitement avant d'être utilisés. Le traitement en question est différent suivant la voie de destination, histoire que deux adresses mémoires avec des index identiques donnent des index différents après traitement. Le traitement en question est souvent une permutation des bits de l'index, qui est différente suivant la voie prise, ou un simple XOR avec un nombre qui dépend de la voie. [[File:Implémentation d'un cache skew associative.jpg|centre|vignette|upright=2|Implémentation d'un cache skew associative.]] ==Les caches splittés (''phased caches'')== Dans cette section, nous allons voir les '''caches splittés''' (''phased caches''), qui sont une variante des caches ''direct-mapped'', dans lequel le cache est accédé en deux étapes consécutives. Il ne s'agit pas des caches pipelinés, que nous verrons dans le chapitre sur les processeurs pipélinés, mais laissons cela à plus tard. Il est possible d'appliquer la même méthode sur un cache associatif par voie, mais il y a des méthodes plus simples, qui permettent là aussi d’accéder au cache en plusieurs étapes consécutives. L'idée est de scinder le cache en deux : une mémoire pour les tags, une autre pour les données de la ligne de cache. Les bits de contrôle peuvent être mis dans l'une ou l'autre SRAM, mais ils sont souvent mis dans la RAM pour les tags. En faisant cela, quelques optimisations deviennent possibles, afin de réduire la consommation énergétique en contrepartie d'une perte de performance. La technique s'implémente différemment pour les caches totalement associatifs et partiellement associatifs. Les caches totalement associatifs splittés sont ceux formés en combinant un cache associatif avec une CAM et une RAM combinée. On envoie l'adresse à lire/écrire à la mémoire associative, elle répond en envoyant une adresse à la mémoire RAM. L'accès se fait donc en deux temps, avec l'adresse dans la RAM comme intermédiaire. Il est possible de séparer physiquement les deux étapes en insérant un registre entre la CAM et la RAM, ce qui permet aussi de pipeliner l'accès. Mais c'est rarement fait en pratique, car le cout en circuit d'une mémoire CAM est trop important. L'équivalent pour un cache totalement associatif optimisé, sans CAM et RAM séparée, est trop gourmande en interconnexions pour être implémentée. Les caches totalement associatifs splittés sont donc très rares, l'auteur ne connait aucun exemple de processeur avec un tel cache. Il existe une technique équivalente pour les caches ''direct-mapped'', mais elle demande une certaine modification du cache. Dans les caches ''direct-mapped'' non-splittés, on trouve une mémoire SRAM dont chaque mot mémoire contient une ligne de cache entière, tag inclus. Dans leurs versions splittés, la SRAM est séparée en deux : une pour les tags, une autre pour les données. Précisons qu'il s'agit bien de deux mémoires SRAM adressables. L'adresse à laquelle accéder est envoyée à la SRAM des tags, puis ensuite à la SRAM des données si besoin. L'idée est d’accéder aux tags pour déterminer s'il y a un succès de cache ou un défaut, et ensuite d'accéder aux données. On n’accède pas aux données en parallèle des tags. Faire cela est évidemment plus lent. En cas de défaut de cache, le temps d'accès est similaire : le tag ne correspond pas, on n'accède pas à la SRAM pour les données. Par contre, vu qu'on n'a pas activé la SRAM pour les données, on économise un peu d'énergie, ce qui réduit la consommation d'énergie. En cas de succès de cache, on accède à la SRAM pour les tags, puis à celle pour les données. Pas d'économie d'énergie à l'horizon, sans compter que le temps d'accès augmente : on accède au cache en deux étapes au lieu de faire les deux accès en parallèle. [[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]] Précisons cependant que ce design peut avoir deux avantages en termes de performance. Premièrement, le temps d'accès au cache est légèrement amélioré en cas de défaut de cache. En effet, la SRAM des tags est assez petite, idem pour celle des données. Leur temps d'accès est donc plus faible que pour une grosse SRAM contenant données et tags. Le gain en temps d'accès est donc un avantage, qui ne se manifeste surtout en cas de défaut de cache. Un autre avantage est que l'accès au cache se pipeline plus facilement, ce qui fait qu'on peut effectuer plusieurs accès simultanés au cache. Mais nous verrons cela dans quelques chapitres. ===L'exemple des processeurs 386 et du contrôleur de cache 82385=== Il est important de noter que la séparation entre tags et RAM peut être telle que les deux ne sont pas sur la même puce de silicium ! Voire que les deux sont séparés du processeur ! C'était le cas quand les mémoires cache ont été introduites sur les processeurs grand public, notamment sur les premiers processeurs Intel. La miniaturisation n'avait pas avancé au point où placer un cache dans le processeur était possible. Sur le processeur 386 d'Intel, le cache était un cache splitté, séparé du processeur. Concrètement, le processeur i386 était couplé à un contrôleur de cache Intel 82385 contenant les ''tags'' et les bits de contrôle, et une mémoire SRAM pour les lignes de cache. Le 82385 était un composant passif, qui n'était pas un intermédiaire entre processeur et mémoire RAM. Il surveillait ce qui se passait sur le bus de données et répondait à la place de la RAM pour certaines lectures. La conséquence est que c'était un cache ''write through'' : le processeur envoyait les écritures sur le bus mémoire, le cache les voyait passer et agissait en conséquence en cas de succès de cache. Il pouvait commander soit un cache ''direct mapped'', soit associatif à deux voies. La différence entre les deux est que le 82385 était relié à une mémoire SRAM avec un cache ''direct mapped'', deux SRAM pour deux voies. Pour avoir un cache associatif à deux voies, le 82385 devrait gérer deux signaux ''chip select'' pour activer chaque SRAM/voie suivant les besoins. Il avait précisément quatre signaux CS : deux par SRAM, un pour les lectures, un pour les écritures. Notons que les SRAM utilisées devaient être des SRAM avec un bus de données de 32 bits, des SRAM 16 ou 8 bits ne fonctionnaient pas. Le 82385 rajoutait 4 sorties, pour masquer chaque octet dans ces 32 bits. Le choix entre les deux était le fait d'une entrée : la mettre à 0 indiquait un cache ''direct mapeed'', la mettre à 1 forcait un cache à deux voies. Il gérait aussi les accès mémoire non-cacheable, à savoir des accès mémoire qui ne doivent pas être pris en compte par le cache. Pour cela, il avait une entrée NCA (''Non Cacheable Access'') : le cache était "désactivé" quand cette entrée était à 1. C'est un peu une sorte de ''chip select'' pour le 82385, limitée aux accès mémoire. Cette entrée permettait de programmer des intervalles d'adresse auxquels ne pas répondre, en utilisant des circuits de décodage d'adresse adaptés. Il avait aussi une entrée X16, qui permettait d'identifier les accès soit à un composant 16 bits, soit à une entrée-sortie. De tels accès ne doivent pas être mis en cache, ce qui était garantit par cette entrée. Pour rappel, le processeur utilisait un espace d'adressage séparé pour les entrées-sorties, et utilisait donc un bit IO. Ce bit IO pouvait être utilisé pour déterminer le bit X16, qui inhibe l'action du cache. Cette surveillance du bus permettait de gérer une forme limitée de cohérence des caches par invalidation. Dès que le 82385 détectait une prise de contrôle du bus par autre chose que le processeur, il surveillait les adresses transmises sur le bus. En cas de succès de cache, la ligne de cache associée était invalidée. Au-delà de ca, le 82385 avait une entrée FLUSH, qui ordonnait une invalidation totale du cache. Si cette entrée est mise à 1, toutes les lignes de cache sont invalidées. Les ''tags'' sont marqués comme invalides, mais les lignes de cache elles-mêmes ne sont pas touchées. ===L'exemple des processeurs Intel de microarchitecture ''Broadwell''=== Un autre exemple est celui du cache L4 des processeurs Broadwell et de quelques processeurs séparés. Ces processeurs ont une organisation en ''chiplet'' où le processeur incorpore plusieurs puces séparées : une puce pour le processeur proprement dit, une puce nommée ''Crystal Well'' pour le cache L4, et une puce IO pour la communication avec la RAM et la carte mère. Le processeur incorporait un cache L4 de 128 mébioctets, composé de mémoire eDRAM, qui était dispersé entre ''Crystal Well'' et les autres puces. Les données du cache L4 étaient dans ''Crystal Well'', alors que les Tags étaient soit dans le processeur lui-même, soit dans la puce IO ! La puce ''Crystal Well'' était une mémoire DRAM adressable tout ce qu'il y a de plus basique, avec cependant quelques optimisations notables. Par exemple, elle avait deux bus séparés pour l'écriture et la lecture. De plus, elle avait une organisation interne avec 128 banques, contre moins d'une dizaine pour la DDR de l'époque et environ 32 banques pour la DDR5 moderne. Elle contenait aussi quelques circuits pour gérer son rôle de mémoire cache, mais rien en ce qui concerne la gestion des tags eux-mêmes. Sur les processeurs de microarchitecture ''Broadwell'', les tags étaient placés dans le CPU et précisément dans le cache L3. À chaque accès mémoire au cache L3, les tags du cache L4 étaient consultés en parallèle. De fait, l'accès au cache L4 était assez rapide, malgré le fait que les données étaient dans une puce à part. Ajoutons à cela que le processeur et ''Crystal Well'' n'avaient pas la même finesse de gravure ni la même technologie de fabrication. Les tags étaient implémentés avec de la SRAM contre la DRAM pour les données, ce qui fait que la consultation des tags était plus rapide que l'accès aux données. Par la suite, dans certains CPU de microarchitecture ''skylake'', les tags ont été déplacés en-dehors du processeur pour finir dans le contrôleur mémoire. En faisant cela, le cache L4 pouvait être utilisé par autre chose que le processeur, et notamment par la carte graphique intégrée au CPU. Avec ''broadwell'', le fait que les tags étaient consultés en cas d'accès au L3 empêchait au GPU intégré de consulter le cache L4. Mais en déplaçant les tags dans le contrôleur mémoire, ce n'est plus le cas vu que la carte graphique a aussi accès au bus mémoire. Par contre, le temps d'accès augmente comparé à la solution précédente. On n'accède pas aux tags du L4 en parallèle du L3 : à la place, il faut consulter les tags du L3, détecter un défaut de cache L3, et ensuite accèder aux tags. ===Les caches RAM-configurables=== Un autre avantage des caches splittés est qu'on peut les modifier pour servir à la fois de mémoire cache, mais aussi de ''local store'', de mémoire RAM de petite taille. Le fonctionnement est assez simple à comprendre. Lors d'un accès au cache, on accède aux tags, puis à la RAM interne au cache. Lors d'un accès au ''local store'', on contourne l'accès au tags et on accède à la RAM interne au cache directement. Il s'agit de la technique du '''cache RAM-configurable''. L'usage de cache RAM-configurable est fréquent sur les cartes graphiques récentes, qui incorporent un ou plusieurs processeurs multicoeurs, dont le cache L1 de données est un cache RAM-configurable. [[File:Hydride cache - local store.png|centre|vignette|upright=2.0|Hydride cache - local store]] ===La compression de cache=== Une autre optimisation permise par les ''phased caches'' est l'implémentation de techniques de '''compression de cache''', qui visent à compresser des lignes de cache. L'intérêt est qu'on peut stocker plus de données dans le cache, à capacité égale. L'inconvénient est qu'on doit compresser/décompresser les lignes de cache, ce qui demande un circuit en plus et allonge les temps d'accès. En effet, le temps mis pour compresser/décompresser une ligne de cache s'ajoute au temps d'accès. Aussi, la compression de cache sert surtout pour les caches de bas niveau dans la hiérarchie mémoire, les gros caches aux temps d'accès assez longs. Une première technique, assez simple à implémenter et peu couteuse en circuit, est celle de la '''compression des lignes de cache nulles'''. Elle compresse uniquement les lignes de cache qui ne contiennent que des zéros. L'idée est qu'on ajoute, dans la mémoire des tags, un bit de contrôle pour chaque ligne de cache appelé le bit ''null''. Il indique si la ligne de cache ne contient que des zéros. Quand on lit une ligne de cache, la mémoire des tags est accédée et on vérifie le bit ''null'' : s'il vaut 1, on n'accède pas à la mémoire cache de données et un multiplexeur envoie un zéro sur le port de lecture. Le bit ''null'' est fixé lors de l'écriture d'une ligne de cache : elle passe dans un comparateur avec zéro relié à la mémoire des tags. La comparaison avec zéro peut se faire en parallèle de l'écriture ou avant (dans ce cas, on n'écrit pas la ligne de cache dans le cache). Les autres techniques de compression de cache permettent de compresser autre chose que des lignes de cache nulles. L'idée est qu'une ligne de cache physique peut par moment mémoriser plusieurs lignes de caches compressées. Par exemple, prenons un cache dont les lignes de cache font 64 octets. Il est possible de compresser deux lignes de cache pour qu'elles fassent chacune 32 octets, et les stocker dans une seule ligne de cache. Les deux lignes de cache auront des tags différents, mais pointeront sur la même ligne de cache physique. Et cela demande d'utiliser un ''phased cache'' dont la mémoire pour les tags est plus grande que la mémoire pour les données. Il n'y a donc plus une bijection entre tags et ligne de cache, mais une relation surjective. Chose qui n'est possible qu'avec un ''phased cache''. De plus, des bits de contrôles associés à chaque ''tag'' indiquent où se trouvent les lignes de cache compressées dans la ligne de cache : est-ce que c'est les 32 octets de poids fort ou de poids faible ? [[File:Compression de cache.png|centre|vignette|upright=2|Compression de cache]] Il ne semble pas que les techniques de compression de cache soient implémentées sur les processeurs modernes. Aucun n'utilise de compression de cache, à ma connaissance. Il faut dire que les techniques connues sont de mauvais compromis : le temps d'accès du cache augmente beaucoup, le cout en circuit pourrait être utilisé pour un cache non-compressé mais plus grand. Et notons que la compression de cache ne marche que si les données peuvent se compresser. Si ce n'est pas le cas, une partie de la mémoire des tags est inutilisée. Une revue de la littérature académique sur la compression de cache est disponible via ce lien, pour les curieux : * [https://inria.hal.science/hal-03285041 Understanding Cache Compression, par Carvalho et Seznec]. ==L'adressage physique ou logique des caches== Le cache utilise les adresses à lire/écrire pour déterminer s'il a une copie de la donnée en son sein. Mais l’interaction entre caches et mémoire virtuelle donne lieu à un petit problème : l'adresse utilisée est-elle une adresse virtuelle/logique ou physique ? La réponse varie suivant le processeur : certains caches utilisent l'adresse virtuelle, tandis que d'autres prennent l'adresse physique. On parle de cache '''virtuellement tagué''' dans le premier cas et de cache '''physiquement tagué''' dans le second. {| |[[File:Cache tagué virtuellement.png|vignette|Cache tagué virtuellement.]] |[[File:Cache tagué physiquement.png|vignette|Cache tagué physiquement.]] |} ===L'accès à un cache physiquement/virtuellement tagué=== La manière d'accéder à un cache dépend de s'il est virtuellement ou physiquement tagué. Il faut utiliser l'adresse virtuelle pour les premiers, physique pour les seconds. Avec un cache virtuellement tagué, l'adresse logique peut être envoyée directement au cache. La MMU ne traduit les adresses que s'il faut accéder à la mémoire RAM. Ces caches sont donc plus rapides. Avec un cache physiquement tagué, le processeur doit traduire l'adresse logique en adresse physique dans la MMU, avant d'accéder au cache. La traduction d'adresse se fait soit en accédant à une table des pages en mémoire RAM, soit en accédant à un cache spécifiquement dédié à accélérer la traduction d'adresse, la TLB (''Translation Lookaside Buffer''). Dans la quasi-totalité des cas, la traduction d'adresse passe par la TLB, ce qui fait qu'elle est raisonnablement rapide. Toujours est-il que chaque accès au cache demande d'accéder à la TLB et de faire la traduction d'adresse avant d'accéder au cache. L'accès est donc plus lent que sur les caches virtuellement tagués, où les accès sont plus directs. [[File:Virtual and Physical addressing.svg|centre|vignette|upright=2|Cache tagué virtuellement versus physiquement tagué.]] ===Les défauts des caches virtuellement tagués=== Les caches physiquement tagués sont moins rapides que les caches virtuellement adressés. Pourtant, les caches virtuellement tagués sont peu fréquents sur les processeurs modernes. Et la raison est assez intéressante : c'est une question d'adresses homonymes et synonymes. ====Les droits d'accès doivent être vérifiés lors d'un accès au cache==== Un premier problème est que la protection mémoire est compliquée avec de tels caches. Rappelons que certaines portions de mémoire sont accessibles seulement en lecture, ou sont interdites en écriture, sont inexécutables, etc. Ces droits d'accès sont gérés par la MMU, qui vérifie pour chaque accès mémoire que l'accès est autorisé. En bypassant la MMU, l'accès au cache virtuellement tagué ne permet pas de faire ces vérifications. Il est possible de charger une donnée en lecture seule dans le cache, mais d'y faire des accès en écriture pour les accès ultérieurs. Les solutions à cela sont multiples. La première consiste à consulter la MMU en parallèle de l'accès au cache. L'accès au cache est alors réalisé de manière spéculative, et est ensuite confirmé/annulé une fois que la MMU a rendu son verdict. Les performances du cache restent alors les mêmes : l'accès à la MMU se fait en parallèle de l'accès au cache, pas avant. Une autre solution est d'ajouter les droits d'accès en question dans la ligne de cache, dans les bits de contrôle situés après le Tag. Chaque accès au cache récupère ces bits de contrôle et vérifie si l'accès est autorisé. L'inconvénient est que les lignes de cache deviennent plus longues, les droits d'accès sont dupliqués entre MMU et cache. Mais si le budget en transistor suit, ce n'est rien d'insurmontable. ====Les adresses homonymes perturbent la gestion du cache==== Pour rappel, une adresse logique homonyme correspond à plusieurs adresses physiques différentes. Elles surviennent quand chaque programme a son propre espace d'adressage. Dans ce cas, une adresse logique correspondra à une adresse physique différente par programme.Une autre manière de voir les choses est qu'il y a en réalité deux adresses homonymes, qui ont la même valeur, mais appartiennent à des espaces d'adressage différentes. Et c'est cette seconde interprétation que nous allons utiliser. Les caches doivent gérer ces adresses homonymes et faire en sorte que la lecture/écriture d'une adresse homonyme se fasse à la bonne adresse physique, dans la bonne ligne de cache. Et autant un cache physiquement tagué n'a aucun problème avec ça, vu qu'il ne gère que des adresses physiques, autant des problèmes surviennent avec les caches virtuellement tagués. Le problème est que les caches virtuellement tagués doivent faire la différence entre deux adresses homonymes de même valeur. Pour corriger ces problèmes, il existe deux grandes méthodes. La première méthode est simple : '''vider les caches''' en changeant de programme. Leur contenu est rapatrié en mémoire RAM, puis les caches sont remis à zéro. Le vidage du cache recopie les lignes de cache ''dirty'' (modifiées) en RAM, puis efface/invalide tout le cache. C'est à cela que servent les instructions ''clean'' et d'invalidation vues plus haut, elles ont été inventées pour cette situation précise. Lorsque le système d'exploitation déclenche une commutation de contexte, à savoir qu'il change le programme en cours d'exécution, le processeur vide tous les caches du processeur. Les interruptions font la même chose, elles vide tous les caches du processeur. Une seconde méthode numérote chaque programme en cours d'exécution, chaque processus. Le numéro attribué est spécifique à chaque processus, ce qui fait qu'il est appelé un '''identifiant de processus CPU'''. Le processeur mémorise l'identifiant du programme en cours d'exécution dans un registre dédié. L'identifiant de processus CPU est utilisé lors des accès mémoire. Chaque ligne de cache contient le numéro de l'espace d'adressage associé, dans son ''tag''. Lors de chaque accès mémoire, l'ID du registre est comparé à l'ID de la ligne de cache accédée, pour vérifier que l'accès mémoire accède à la bonne donnée. Cette méthode n'est pas très économe en termes de transistors. L'usage d'identifiant de processus CPU est clairement meilleure en termes de performance, les commutations de contexte sont plus rapides. Par contre, le budget en transistor est plus important. Un autre défaut de cette méthode est que l'identifiant de processus est généralement codé sur une dizaine de bits, alors que le système d'exploitation utilise des identifiants de processus beaucoup plus larges, de 32 à 64 bits sur les CPU 32/64 bits. L'OS doit gérer la correspondance entre identifiants de processus CPU et ceux de l'OS. Parfois, pour cette raison, les OS n'utilisent pas toujours ce système d'identifiant de processus CPU. ====Les adresses synonymes perturbent aussi la gestion du cache==== La gestion des adresses synonymes est aussi un gros problème sur les caches virtuellement tagués. Pour rappel, il s'agit du cas où des adresses logiques différentes pointent vers la même adresse physique. Typiquement, quand deux programmes se partagent un morceau de mémoire, ce morceau correspondra à des adresses synonymes dans les deux espaces d'adressage. Mais il arrive que l'on ait des adresses synonymes dans le même espace d'adressage, ce n'est pas si rare ! Autant les adresses synonymes ne posent aucun problème avec les caches physiquement tagués, ce n'est pas le cas avec les caches virtuellement adressés. Sur ces caches, deux adresses logiques synonymes vont tomber dans deux lignes de cache différentes. Corriger ce problème demande d'ajouter des circuits annexes pour détecter les adresses synonymes, qui sont vraiment complexes et ont un cout en termes de performance. Aussi, les caches virtuellement tagués sont très peu utilisés sur les processeurs modernes. ===Les caches virtuellement adressés, mais physiquement tagués=== Si les caches physiquement et virtuellement tagués ont des défauts, il existe un intermédiaire qui est un bon compromis entre ces deux extrêmes. Il s'agit des '''caches virtuellement adressés - physiquement tagués''', aussi appelés '''caches pseudo-virtuels'''. Pour comprendre comment ils fonctionnent, précisons que ces caches sont soit des caches ''direct-mapped'', soit des caches associatifs par voie (composés de plusieurs RAM ''direct-mapped'' accédées en parallèle, plusieurs voies). L'accès à ce genre de cache se fait en deux temps : on accède à un ou plusieurs RAM ''direct-mapped'' et on vérifie ensuite les ''Tags'' pour sélectionner la bonne voie. Sur les caches ''direct-mapped'', on n'a qu'une seule RAM ''direct-mapped''. Sur les caches associatifs, on a plusieurs RAM ''direct-mapped'', appelées des voies, qui sont accédées en parallèle. L'accès se fait donc en deux étapes : adresser les RAM ''direct-mapped'' avec un indice, vérifier les ''tags'' avec le reste de l'adresse. Une autre chose à rappeler est que l'adresse logique est composée de deux parties : un numéro de page logique qui indique dans quel page se situe l'adresse, un décalage/''offset'' qui indique la position de l'adresse dans la page. La traduction d'adresse transforme le numéro de page logique en numéro de page physique, mais laisse le décalage intouché. L'idée est d'utiliser le décalage pour adresser les RAM avec le décalage, tandis que le numéro de page sert de ''tag''. Le décalage est découpé en deux lors de l'accès au cache : les bits de poids fort forment l'indice (l'adresse envoyée à la voie), les bits de poids faible donnent la position de l'adresse dans la ligne de cache. L'idée est d'utiliser un numéro de page physique pour les ''tags'', mais d'adresser les voies avec le décalage logique. Les deux servent à des instants différents : vérification des ''tags'' pour l'adresse physique, accès aux voies pour l'adresse logique. Ainsi, le problème des adresses synonymes ou homonymes est résolu par l'utilisation de l'adresse physique pour les tags. Par contre, l'accès au cache est plus rapide, car on utilise l'adresse logique pour la première étape. Le processeur accède à la TLB et récupère l'adresse physique pendant que l'on adresse les voies, les deux sont faits en parallèle, ce qui fait que tout se passe comme si l'accès à la TLB était gratuit. La TLB étant assez rapide comparé au cache, l'adresse physique est disponible quand on doit faire la comparaison avec les ''tags''. [[File:Virtual - Physical - Pseudo Virtual addressing.svg|centre|vignette|upright=2|Adressage pseudo virtuel des caches.]] Il s'agit d'un excellent compromis entre performance et correction des problèmes des adresses synonymes/homonymes. Tous les caches des processeurs haute performance utilisent cette méthode, au moins pour leurs caches L1. Les caches L2 tendent à utiliser des caches physiquement adressés, pour lesquels la latence d'accès est suffisante pour qu'on accède à la TLB en amont. La raison est assez simple à expliquer, elle provient d'une contrainte assez précise sur le calcul de l'indice. La conséquence est qu'un cache ''direct-mapped'' ne peut pas dépasser la taille d'une page, soit 4 kibioctets sur les ordinateurs actuels. Sur les caches associatifs, on peut dépasser cette limite en augmentant le nombre de voies, mais la taille maximale d'une voie reste celle d'une page. Cette contrainte n'est pas trop grave sur les caches de petite taille, dont les caches L1. La plupart d'entre eux ont trouvé un compromis idéal avec moins d'une dizaine de voies par cache, chacun de 4 kibioctets, ce qui donne des caches allant de 16 à 64 kibioctets, soit entre 4 et 16 voies. Par contre, un cache de grande taille doit utiliser un grand nombre de voies, ce qui est peu pratique. Aussi, cette technique de caches pseudo-virtuels n'est pas toujours appliquée sur les caches L2, qui sont physiquement adressés. Il faut dire qu'on accède au cache L2 lors d'un défaut dans le cache L1, et l'adresse physique est disponible à ce moment-là, elle a déjà été récupérée lors de l'accès au cache L1. On peut donc l'utiliser pour adresser le cache L2 sans perte de performance. ==Le remplacement des lignes de cache== Lorsqu'un cache est rempli et qu'on charge une nouvelle donnée dedans, il faut faire de la place pour cette dernière. Dans le cas d'un cache directement adressé, il n'y a rien à faire vu que la ligne de cache à évincer est déterminée lors de la conception du cache. Mais pour les autres caches, la donnée peut aller dans n'importe quelle ligne ou voie. Or, le choix des données à rapatrier en RAM doit être le plus judicieux possible : on doit virer de préférence des données inutiles. Rapatrier une donnée qui sera surement utilisée sous peu est inutile, et il vaudrait mieux supprimer des données qui ne serviront plus ou alors dans longtemps. Il existe différents algorithmes spécialement dédiés à résoudre ce problème efficacement, directement câblés dans les unités de gestion du cache. Certains sont vraiment très complexes, aussi je vais vous présenter quelques algorithmes particulièrement simples. Mais avant de voir ces algorithmes, il faut absolument que je vous parle d'une chose très importante. Quel que soit l'algorithme en question, il choisit la ligne de cache à évincer et recopie son contenu dans la RAM. Ce qui demande d'identifier et de sélectionner une ligne de cache parmi toutes les autres. Pour cela, le circuit de remplacement attribue une adresse chaque ligne de cache ! Vous avez bien vu : chaque ligne de cache est numérotée par une adresse, interne au cache. ===Le remplacement aléatoire=== Premier algorithme : la donnée effacée du cache est choisie au hasard ! C'est contre-intuitif, mais cet algorithme donne des résultats assez honorables, en plus d'utiliser très peu de portes logiques (un générateur de nombres pseudo-aléatoire est un circuit assez simple). Généralement, les défauts de cache sont séparés par un nombre assez important et irrégulier de cycles d'horloge. Dans ces conditions, cette technique donne un bon résultat. ===FIFO : first in, first out=== Avec l'algorithme FIFO, la donnée effacée du cache est la plus ancienne, celle chargée dans le cache avant les autres. Cet algorithme est très simple à implémenter en circuit, concevoir une mémoire de type FIFO n'étant pas très compliqué, comme on l’a vu dans le chapitre dédié à ce type de mémoires. Et on peut dire que dans le cas d'un cache, l'implémentation est encore plus simple et se contente d'un seul registre/compteur. Typiquement, il suffit d'ajouter un registre qui mémorise où se situe la donnée la plus récente. Toute insertion d'une nouvelle donnée se fait à l'adresse suivante, ce qui demande juste d'incrémenter le registre avant d'utiliser son contenu pour l'accès mémoire. [[File:Algorithme FIFO de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme FIFO de remplacement des lignes de cache.]] Cet algorithme possède une petite particularité sur les caches associatifs par voie : en augmentant le nombre d'ensembles, les performances peuvent se dégrader : c'est ce qu'on appelle l''''anomalie de Bélády'''. ===MRU : most recently used=== Avec l'algorithme MRU, la donnée remplacée est celle qui a été utilisée le plus récemment. Cet algorithme s'implémente simplement avec un registre, dans lequel on place le numéro de la dernière ligne de cache utilisée. Cet algorithme de remplacement est très utile quand un programme traverse des tableaux du premier élément jusqu'au dernier : les données du tableau sont rarement réutilisées, rendant le cache inutile. Il est prouvé que dans ces conditions, l'algorithme MRU est optimal. Mais dans toutes les autres conditions, cet algorithme a des performances assez misérables. ===LFU : least frequently used=== Avec l'algorithme LFU, la donnée supprimée est celle qui est utilisée le moins fréquemment. Cet algorithme s'implémente en associant un compteur à chaque ligne de cache, qui est incrémenté à chaque accès mémoire. La ligne la moins récemment utilisée est celle dont le compteur associé a la plus petite valeur. Implémenter cet algorithme prend pas mal de transistors, car il faut rajouter autant de compteurs qu'il y a de lignes de cache, en plus d'un circuit pour comparer les compteurs et d'un encodeur. [[File:Algorithme LFU de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme LFU de remplacement des lignes de cache]] ===LRU : least recently used=== Avec l'algorithme LRU, la donnée remplacée est celle qui a été utilisée le moins récemment. Cet algorithme se base sur le principe de localité temporelle, qui stipule qu'une donnée accédée récemment a de fortes chances d'être réutilisée dans un futur proche. Et inversement, la donnée la moins récemment utilisée du cache est celle qui a le plus de chance de ne servir à rien dans le futur. Autant la supprimer en priorité pour faire de la place à des données potentiellement utiles. Implémenter l'algorithme LRU peut se faire de différentes manières, qui ont pour point commun d'enregistrer les accès au cache pour en déduire la ligne la moins récemment accédée. La manière la plus simple demande d'utiliser un compteur pour chaque ligne de mémoire cache, un peu comme le LFU. La différence avec le LFU est que le compteur n'est pas incrémenté lors d'un accès mémoire. À la place, ce compteur est incrémenté régulièrement, chaque incrémentation ayant lieu en même temps pour tous les compteurs. Quand un bloc est chargé dans le cache, ce compteur est mis à zéro. Quand une ligne de cache doit être remplacée, un circuit va vérifier la valeur de tous les compteurs : la ligne LRU (la moins récemment utilisée), est celle dont le compteur a la valeur la plus haute. Le circuit est composé d'un paquet de comparateurs, et d'un encodeur, comme pour l'agorithme LFU. ===Les approximations du LRU=== Implémenter le LRU demande un nombre de transistors proportionnel au carré du nombre de lignes de cache. Autant dire que le LRU devient impraticable sur de gros caches. Ce qui fait que les processeurs modernes implémentent des variantes du LRU, moins couteuses en transistors, qui donnent un résultat approximativement semblable au LRU. En clair, ils ne sélectionnent pas toujours la ligne de cache la moins récemment utilisée, mais une ligne de cache parmi les moins récemment utilisées. Ce n'est pas un problème si grave que cela car les lignes les moins récemment utilisées ont toutes assez peu de chance d'être utilisées dans le futur. Entre choisir de remplacer une ligne qui a 0,5 % de chances d'être utilisée dans le futur et une autre qui a une chance de seulement 1 %, la différence est négligeable en termes de taux de succès. Mais les gains en termes de circuits ou de temps d'accès au cache de ces algorithmes sont très intéressants. L'algorithme le plus simple consiste à couper le cache (ou chaque voie s'il est associatif) en plusieurs sections. L'algorithme détermine la section la moins récemment utilisée, avant de choisir aléatoirement une ligne de cache dans cette section. Pour implémenter cet algorithme, il nous suffit d'un registre qui mémorise le morceau le moins récemment utilisé, et d'un circuit qui choisit aléatoirement une ligne de cache. Cette technique s'adapte particulièrement bien avec des caches associatifs à voies : il suffit d'utiliser autant de morceaux que de voies. Autre algorithme, un peu plus efficace : le '''pseudo-LRU de type M'''. Cet algorithme attribue un bit à chaque ligne de cache, bit qui sert à indiquer de façon approximative si la ligne de cache associée est une candidate pour un remplacement ou non. Il vaut 1 si la ligne n'est pas une candidate pour un remplacement et zéro sinon. Le bit est mis à 1 lorsque la ligne de cache associée est lue ou écrite. Évidemment, au fil du temps, toutes les lignes du cache finiront par avoir leur bit à 1. Lorsque cela arrive, l'algorithme remet tous les bits à zéro, sauf pour la dernière ligne de cache accédée. L'idée derrière cet algorithme est d'encercler la ligne de cache la moins récemment utilisée au fur et à mesure des accès. L'encerclement commence lorsque l'on remet tous les bits associés aux lignes de cache à 0, sauf pour la ligne accédée en dernier. Au fur et à mesure des accès, l'étau se resserre autour de la ligne de cache la moins récemment utilisée. Après un nombre suffisant d'accès, l'algorithme donne une estimation particulièrement fiable. Et comme les remplacements de lignes de cache sont rares comparés aux accès aux lignes, cet algorithme finit par donner une bonne estimation avant qu'on ait besoin d'effectuer un remplacement. Le dernier algorithme d'approximation, le '''PLURt''', se base sur ce qu'on appelle un arbre de décision. Il a besoin de n − 1 bits pour déterminer la ligne LRU. Ces bits doivent être organisés en arbre, comme illustré plus bas. Chacun de ces bits sert à dire : le LRU est à ma droite ou à ma gauche : il est à gauche si je vaux 0, et à droite si je vaux 1. Trouver le LRU se fait en traversant cet arbre, et en interprétant les bits un par un. Au fur et à mesure des lectures, les bits sont mis à jour dans cet arbre, et pointent plus ou moins bien sur le LRU. La mise à jour des bits s'effectue lors des lectures et écritures : quand une ligne est lue ou écrite, elle n'est pas la ligne LRU. Pour l'indiquer, les bits à 1 qui pointent vers la ligne de cache sont mis à 0 lors de la lecture ou écriture. {| |[[File:Organisation des bits avec l'algorithme PLURt.jpg|vignette|Organisation des bits avec l'algorithme PLURt.]] |[[File:Ligne de cache pointée par les bits de l'algorithme.png|vignette|Ligne de cache pointée par les bits de l'algorithme.]] |} ===LRU amélioré=== L'algorithme LRU, ainsi que ses variantes approximatives, sont très efficaces tant que le programme respecte relativement bien la localité temporelle. Par contre, Le LRU se comporte assez mal dans les circonstances ou la localité temporelle est mauvaise mais où la localité spatiale est respectée, le cas le plus emblématique étant le parcours d'un tableau. Pour résoudre ce problème, des variantes du LRU existent. Une variante très connue, l''''algorithme 2Q''', utilise deux caches : un cache FIFO pour les données accédées une seule fois et un second cache LRU. Évidemment, les données lues une seconde fois sont migrées du cache FIFO vers le cache LRU, ce qui n'est pas très pratique. Les processeurs n'utilisent donc pas cette technique, mais celle-ci est utilisée dans les caches de disque dur. D'autres variantes du LRU combinent plusieurs algorithmes à la fois et vont choisir lequel de ces algorithmes est le plus adapté à la situation. Notre cache pourra ainsi détecter s’il vaut mieux utiliser du MRU, du LRU, ou du LFU suivant la situation. ==Les écritures dans le cache : gestion et optimisations== Les écritures se font à une adresse mémoire bien précise, qui peut ou non être chargée dans le cache. Si la donnée à écrire est chargée dans le cache, elle est modifiée directement dans le cache, mais elle ne l'est pas forcément en mémoire RAM. Suivant le processeur, les écritures sont ou non propagées en mémoire RAM. Il existe deux stratégies d'écritures, appelées respectivement le ''write-back'' et le ''write-through''. Avec un cache ''write-back'', si la donnée à mettre à jour est présente dans le cache, on écrit dans celui-ci sans écrire dans la mémoire RAM. Dans ces conditions, une donnée n'est enregistrée en mémoire que si celle-ci quitte le cache, ce qui évite de nombreuses écritures mémoires inutiles. [[File:Cache write-through.png|centre|vignette|upright=2|Cache write-through.]] Avec les caches '''Write-Through''', toute écriture dans le cache est propagée en RAM. Cette stratégie augmente le nombre d'écritures dans la mémoire RAM, ce qui peut saturer le bus reliant le processeur à la mémoire. Les performances de ces caches sont donc légèrement moins bonnes que pour les caches ''write back''. Par contre, ils sont utiles dans les architectures avec plusieurs processeurs, comme nous le verrons dans les chapitres sur les architectures multiprocesseurs. [[File:Cache write-back.png|centre|vignette|upright=2|Cache write-back.]] ===Les caches ''Write-through''=== Sans optimisation particulière, on ne peut écrire dans un cache ''write-through'' pendant qu'une écriture en RAM a lieu en même temps : cela forcerait à effectuer deux écritures simultanées, en comptant celle imposée par l'écriture dans le cache. Pour éviter cela, certains caches ''write-through'' intègrent un '''tampon d’écriture''', qui sert de file d'attente pour les écritures en RAM. C'est une mémoire FIFO dans laquelle on place temporairement les données à écrire en RAM, où elles attendent en attendant que la RAM soit libre. Grâce à lui, le processeur peut écrire dans un cache même si d'autres écritures sont en attente dans le tampon d'écriture. Par souci d'efficacité, des écritures à la même adresse en attente dans le tampon d’écriture sont fusionnées en une seule. Cela fait un peu de place dans le tampon d’écriture, et lui permet d'accumuler plus d'écritures avant de devoir bloquer le cache. Il est aussi possible de fusionner des écritures à adresses consécutives de la mémoire en une seule écriture en rafales. Dans les deux cas, on parle de '''combinaison d'écriture'''. Mais la technique du tampon d'écriture a cependant un léger défaut qui se manifeste dans une situation bien précise : quand le processeur veut lire une donnée en attente dans le tampon d’écriture. La première manière de gérer cette situation est de mettre en attente la lecture tant que la donnée n'a pas été écrite en mémoire RAM. On peut aussi lire la donnée directement dans le tampon d'écriture, cette optimisation portant le nom de '''''store-to-load forwading'''''. Dans tous les cas, il faut détecter le cas où une lecture accède à une donnée dans le tampon d'écriture. À chaque lecture, l'adresse à lire est envoyée au tampon d'écriture, qui vérifie si une écriture en attente se fait à cette adresse. Pour cela, le tampon d’écriture doit être un cache, dont chaque entrée mémorise une écriture. Chaque ligne de cache contient la donnée à écrire, et le tag de la ligne de cache contient l'adresse où écrire la donnée. Notons que cache d'écriture a une politique de remplacement de type FIFO, le tampon d'écriture non-optimisé étant une mémoire FIFO. ===Les caches ''Write-back''=== Les caches ''write-back'' ont beau avoir des performances supérieures à celles des caches ''write-through'', il existe des optimisations qui permettent d'améliorer leurs performances. Ces optimisations consistent à ajouter des caches spécialisés à côté du cache proprement dit. Ces caches permettent de mémoriser des données qui sont éliminées du cache par les algorithmes de remplacement de ligne cache, sans pour autant faire une écriture en RAM. En suivant la procédure habituelle de remplacement des lignes de cache, on doit rapatrier la ligne en RAM avant d'en charger une nouvelle. On peut améliorer la situation en faisant l'inverse : on charge la nouvelle ligne pendant que l'ancienne donnée est rapatriée en RAM. Ainsi, la nouvelle ligne est disponible plus tôt pour le processeur, diminuant son temps d'attente. Pour implémenter cette technique, on doit mémoriser l'ancienne ligne de cache temporairement dans un '''cache d’éviction''' (ou ''write-back buffer''). [[File:Cache d’éviction.png|centre|vignette|upright=2|Cache d’éviction]] Les caches directement adressés ou associatifs par voie possèdent aussi un tampon d’écriture amélioré. Pour limiter les défauts par conflit de ces caches, des scientifiques ont eu l'idée d'insérer un cache pour stocker les données virées du cache. En faisant ainsi, si une donnée est virée du cache, on peut alors la retrouver dans ce cache spécialisé. Ce cache s'appelle le '''cache de victime'''. Ce cache de victime est géré par un algorithme de suppression des lignes de cache de type FIFO. Petit détail : ce cache utilise un tag légèrement plus long que celui du cache directement adressé au-dessus de lui. L'index de la ligne de cache doit en effet être contenu dans le tag du cache de victime, pour bien distinguer deux adresses différentes, qui iraient dans la même ligne du cache juste au-dessus. [[File:Victim Cache Implementation Example.svg|centre|vignette|upright=1|Cache de victime.]] ===La configuration du fonctionnement du cache=== Sur de nombreux processeurs, il est possible de configurer la mémoire cache pour qu'elle fonctionne soit en mode ''write-back'', soit en mode ''write-through''. Pour cela, les processeurs modernes incorporent des '''registres de configuration du cache'''. Le terme ''registre de configuration du cache'' est assez transparent et indique bien quel est leur rôle. Ils configurent comment le cache est utilisé et permettent notamment de configurer le cache pour dire s'il doit fonctionner en mode ''write-back'' ou ''write-through''. Ils permettent aussi d'activer ou de désactiver la combinaison sur écriture. Les registres en question sont configurés soit par le BIOS, soit par le système d'exploitation. Ce sont des registres protégés, que les applications ne peuvent pas configurer, elles n'en ont pas le droit. Typiquement, ils ne sont accessibles en écriture qu'en mode noyau. Sur les processeurs x86, les registres de configuration du cache sont appelés des '''''Memory type range registers''''' (''MTRRs''). Les MTRRs sont assez nombreux, et il y a notamment une différence entre mode réel et protégé. Si vous vous souvenez des chapitres sur le mode d'adressage et la mémoire virtuelle, vous vous souvenez que les processeurs x86 incorporent plusieurs modes de fonctionnement. En mode réel, le processeur ne peut adresser qu'un mébioctet de RAM, avec un système de segmentation particulier. En mode protégé, le processeur peut adresser toute la mémoire et la segmentation fonctionne différemment, quand elle n'est pas simplement désactivée. Les MTRRs sont séparés en deux : ceux pour le mode réel, ceux pour le mode protégé. Les MTRRs fixes sont ceux qui configurent le cache en mode réel, ils étaient utilisés pour gérer l'accès au BIOS, à la mémoire VGA de la carte graphique, et quelques autres accès aux entrées-sorties basiques gérées nativement par le BIOS. Pour le mode protégé, les processeurs au-delà du 386 incorporent des MTRRs variables, qui servent pour les autres entrées-sorties en général, notamment les périphériques PCI, la mémoire vidéo de la carte graphique, et j'en passe. De nos jours, les registres de configuration du cache sont désuets et cette fonctionnalité est gérée directement par la mémoire virtuelle. La table des pages contient, pour chaque page mémoire, des bits de contrôle qui disent si la page mémoire est cacheable ou non. Le contournement de cache est alors géré par le système de mémoire virtuelle, le cache de TLB et tout ce qui va avec. ===L’allocation sur écriture=== Que faire quand une écriture modifie une donnée qui n'est pas dans le cache ? Doit-on écrire la donnée dans le cache, ou non ? Si la donnée est écrite dans le cache, on dit que le cache fait une '''allocation sur l'écriture''' (ou ''write-allocate''). Certains caches effectuent une telle allocation sur écriture, mais d'autres ne le font pas ou du moins pas systématiquement. L’allocation sur écriture peut se décliner en deux sous-catégories : le '''chargement à la demande''' et l''''écriture immédiate'''. Dans le premier cas, on charge la donnée à modifier dans le cache, et on la remplace avec la donnée écrite. Dans l'écriture immédiate, l'écriture a lieu directement dans le cache et la donnée à modifier n'est pas chargée dans le cache. Évidemment, seule une portion de la ligne de cache contient la donnée écrite (valide), et le reste contient des données invalides. Le cache doit savoir quelles sont les portions du cache qui sont valides : cela demande d'utiliser un ''sector cache''. [[File:Write-back with write-allocation.svg|centre|vignette|upright=2|Cache Write-back avec allocation sur écriture.]] Sans allocation sur écriture, l'écriture est transférée directement aux niveaux de cache inférieurs ou à la mémoire si la donnée à modifier n'est pas dans le cache. Certains caches de ce genre utilisent une petite optimisation : lors de toute écriture, ils supposent que l'écriture donnera un succès de cache. Si c'est le cas, la ligne de cache qui contient la donnée est mise à jour avec la donnée à écrire. Mais si ce n'est pas le cas, la ligne de cache est invalidée, et l'écriture est transférée directement à la mémoire ou aux niveaux de cache inférieurs. [[File:Write-through with no-write-allocation.svg|centre|vignette|upright=2|Cache Write-through sans allocation sur écriture.]] ===La cohérence des caches=== Il arrive parfois que la mémoire d'un ordinateur soit mise à jour, sans que les modifications soient répercutées dans les mémoires cache. Dans ce cas, le cache contient une donnée périmée. Or, un processeur doit toujours éviter de se retrouver avec une donnée périmée et doit toujours avoir la valeur correcte dans ses caches : cela s'appelle la '''cohérence des caches'''. Il est possible de se retrouver avec des valeurs périmées dans le cache sur les ordinateurs avec plusieurs processeurs, ou si un périphérique écrit en RAM, les modifications ne sont pas répercutées automatiquement dans les mémoires cache. Pour résoudre ce problème, on peut interdire de charger dans le cache des données stockées dans les zones de la mémoire dédiées aux périphériques. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires cache. Autre solution : utiliser le fait que les périphériques déclenchent une interruption matérielle pour laisser le contrôleur DMA accéder à la mémoire. Dans ce cas, il suffit de vider les caches à chaque interruption matérielle. Le processeur peut le faire automatiquement, ou fournir des instructions pour. ==Le ''cache bypassing'' : contourner le cache== Dans certaines situations, le cache n'est pas utilisé pour certains accès mémoire. Diverses techniques permettent en effet d'effectuer des accès mémoire qui contournent le cache, qui ne passent pas par le cache. Ils sont utilisés quand l'accès en cache fait que des instructions normales ne fonctionnent pas. Par exemple, de tels accès directs à la RAM sont notamment utilisés pour l'implémentation d'instructions atomiques, une classe d'instructions spécifiques utilisées sur les processeurs multicœurs, dont nous parlerons dans plusieurs chapitres. Mais ils sont aussi utilisés pour l'accès aux périphériques, ce que nous allons voir maintenant. ===Accéder aux périphériques demande de contourner le cache=== Pour rappel, un périphérique (au sens d'entrée-sortie) contient des registres d’interfaçage qui ont une adresse au même titre que les cases mémoire. Un périphérique peut à tout instant modifier ses registres d’interfaçage, ce qui se répercute automatiquement dans l'espace d'adressage, mais rien de tout cela n'est transmis au cache. Si les accès aux périphériques passaient par l'intermédiaire du cache, on aurait droit à des problèmes. On aurait encore une fois droit à des problèmes de cohérence des caches. Le problème est géré différemment suivant que l'on utilise un espace d'adressage séparé ou des entrées-sorties mappées en mémoire. La solution est que les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache. Cela demande d'adapter le cache et le processeur. L'implémentation exacte dépend de comment sont adressés les périphériques. Pour rappel, il y a deux solutions pour adresser les périphériques : soit les périphériques disposent d'un espace d'adressage séparé de celui de la mémoire, soit il y un espace d'adressage unique partagé entre processeur et mémoire. Les deux cas donnent des solutions différentes. Avec un espace d'adressage séparé, l'espace d'adressage des périphériques n'est pas caché : aucun accès dans cet espace d'adressage ne passe par le cache. La mémoire cache n'est utilisée que pour l'espace d'adressage des mémoires, rien d'autre. C'est de loin le cas le plus simple : il suffit de concevoir le processeur pour. Il dispose d'instructions séparées pour les accès aux registres d’interfaçage et à la RAM/ROM, les premières ne passent pas par le cache, les autres si. Avec des entrées-sorties mappées en mémoire, la même solution est utilisée, mais dans une version un peu différente. Là encore, les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache, si on veut qu'ils marchent comme ils le doivent. Cela demande d'adapter le cache et le matériel pour que accès aux périphériques mappés en mémoire contournent le cache. Des adresses, voire des zones entières de la mémoire, sont marquées comme étant non-cachables. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires caches. Là encore, le processeur doit être prévu pour : on doit pouvoir le configurer de manière à marquer certaines zones de la RAM comme non-cacheable. Reste qu'il faut marquer des régions de la RAM comme non-cacheable. Pour cela, on améliore les registres de configuration du cache, vus plus haut, afin qu'ils permettent de configurer certaines portions de la RAM pour préciser qu'elles ne doivent pas être mises en cache, qu'il faut activer le contournement de cache pour celles-ci. ===Contourner le cache pour des raisons de performance=== Il arrive que des données avec une faible localité soient chargées dans le cache inutilement. Or, il vaut mieux que ces données transitent directement entre le processeur et la mémoire, sans passer par l'intermédiaire du cache. Pour cela, le processeur peut fournir des instructions d'accès mémoire qui ne passent pas par le cache, à côté d'instructions normales. De telle instructions sont appelées des '''instructions mémoire non-temporelles'''. Non-temporelle, dans le sens : pas de localité temporelle (c.a.d que les données ne seront pas réutilisées plus tard). Mais il existe aussi des techniques matérielles, où le cache détecte à l'exécution les lectures qui gagnent à contourner le cache. La dernière méthode demande d'identifier les instructions à l'origine des défauts de cache, le processeur accédant directement à la RAM quand une telle instruction est détectée. Si une instruction d'accès mémoire fait trop de défauts de cache, c'est signe qu'elle gagne à contourner le cache. L'idée est de mémoriser, pour chaque instruction d'accès mémoire, un historique de ses défauts de cache. Il existe plusieurs méthodes pour cela, mais toutes demandent d'ajouter de quoi mémoriser l'historique des défauts de cache des instructions. L'historique est mémorisé dans une mémoire appelée la '''table d’historique des défauts de lecture''' (''load miss history table''), qui est souvent un cache. L'historique en question est, dans sa version la plus simple, un compteur de quelques bits incrémenté à chaque succès de cache et décrémenté à chaque défaut de cache, qui indique si l'instruction a en moyenne fait plus de défauts ou de succès de cache. La table associe le ''program counter'' d'une instruction mémoire à cet historique. À la première exécution d'une instruction d'accès mémoire, une entrée de cette table est réservée pour l'instruction. Lors des accès ultérieurs, le processeur récupérer les informations associées et décide s'il faut contourner le cache ou non. ==La hiérarchie mémoire des caches== [[File:Cache Hierarchy.png|vignette|Hiérarchie de caches]] On pourrait croire qu'un seul cache est largement suffisant pour compenser la lenteur de la mémoire. Hélas, les processeurs sont devenus tellement rapides que les caches sont eux-mêmes très lents ! Pour rappel, plus une mémoire peut contenir de données, plus elle est lente. Et les caches ne sont pas épargnés. Si on devait utiliser un seul cache, celui-ci serait très gros et donc trop lent. La situation qu'on cherche à éviter avec la mémoire RAM revient de plus belle. Même problème, même solution : si on a décidé de diviser la mémoire principale en plusieurs mémoires de taille et de vitesse différentes, on peut bien faire la même chose avec la mémoire cache. Depuis environ une vingtaine d'années, un processeur contient plusieurs caches de capacités très différentes : les caches L1, L2 et parfois un cache L3. Certains de ces caches sont petits, mais très rapides : c'est ceux auxquels on va accéder en priorité. Viennent ensuite d'autres caches, de taille variable, mais plus lents. Les processeurs ont donc une hiérarchie de caches qui se fait de plus en plus complexe avec le temps. Cette hiérarchie est composée de plusieurs niveaux de cache, qui vont des niveaux inférieurs proches de la mémoire RAM à des niveaux supérieurs proches du processeur. Plus on monte vers les niveaux supérieurs, plus les caches sont petits et rapides. Un accès mémoire dans une hiérarchie de cache fonctionne comme suit : on commence par vérifier si la donnée recherchée est dans le cache le plus rapide, à savoir le cache L1. Si c'est le cas,n on la charge depuis ce cache directement. Si elle n’y est pas, on vérifie si elle est dans le cache de niveau supérieur, le cache L2. Et rebelote ! Si elle n'y est pas, on vérifie le cache du niveau supérieur. Et on répète cette opération, jusqu’à avoir vérifié tous les caches. Si la donnée n'est dans aucun cache, on doit alors aller chercher la donnée en mémoire. [[File:Hiérarchie de caches.png|centre|vignette|upright=2|Hiérarchie de caches]] Il y a des différences assez notables entre chaque niveau de cache. Par exemple, les différents niveaux de cache n'ont pas forcément les mêmes politiques de remplacement des lignes de cache. Le cache L1 a généralement une politique de remplacement simple, très rapide, mais peu efficace. De même, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Le cache le plus proche de la mémoire est appelé le '''cache de dernier niveau''', ''Last Level Cache'' en anglais. Il a parfois des caractéristiques totalement différentes des autres caches. Par exemple, sur les processeurs multicoeurs, le cache L3 n'est techniquement pas dans le cœur, mais fait partie d'un ensemble de circuits reliés, comme le contrôleur mémoire ou l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc. ===Les caches exclusifs et inclusifs=== Notons que du point de vue de cette vérification, il faut distinguer les caches inclusifs et exclusifs. Avec les caches inclusifs, si une donnée est présente dans un cache, alors elle est présente dans les caches des niveaux inférieurs, ce qui implique l'existence de données en doublon dans plusieurs niveaux de cache. À l'opposé, les caches exclusifs font que toute donnée est présente dans un seul cache, pas les autres. Il existe aussi des caches qui ne sont ni inclusifs, ni exclusifs. Sur ces caches, chaque niveau de cache gère lui-même ses données, sans se préoccuper du contenu des autres caches. Pas besoin de mettre à jour les niveaux de cache antérieurs en cas de mise à jour de son contenu, ou en cas d'éviction d'une ligne de cache. La conception de tels caches est bien plus simple. Dans les '''caches exclusifs''', le contenu d'un cache n'est pas recopié dans le cache de niveau inférieur. Il n'y a pas de donnée en double et on utilise 100 % de la capacité du cache, ce qui améliore le taux de succès. Par contre, le temps d'accès est un peu plus long. La raison est que si une donnée n'est pas dans le cache L1, on doit vérifier l'intégralité du cache L2, puis du cache L3. De plus, assurer qu'une donnée n'est présente que dans un seul cache nécessite aux différents niveaux de caches de communiquer entre eux pour garantir que l'on a pas de copies en trop d'une ligne de cache, ce qui peut prendre du temps. [[File:Caches exclusifs.png|centre|vignette|upright=2|Caches exclusifs]] Dans le cas des '''caches inclusifs''', le contenu d'un cache est recopié dans les caches de niveau inférieur. Par exemple, le cache L1 est recopié dans le cache L2 et éventuellement dans le cache L3. Ce genre de cache a un avantage : le temps d'accès à une donnée est plus faible. La raison est qu'il ne faut pas vérifier tout un cache, mais seulement la partie qui ne contient pas de donnée en doublon. Par exemple, si la donnée voulue n'est pas dans le cache L1, on n'est pas obligé de vérifier la partie du cache L2 qui contient la copie du L1. Ainsi, pas besoin de vérifier certaines portions du cache, ce qui est plus rapide et permet de simplifier les circuits de vérification. En contrepartie, l'inclusion fait que qu'une partie du cache contient des copies inutiles, comme si le cache était plus petit. De plus, maintenir l'inclusion est compliqué et demande des circuits en plus et/ou des échanges de données entre caches. [[File:Caches inclusifs.png|centre|vignette|upright=2|Caches inclusifs]] Maintenir l'inclusion demande de respecter des contraintes assez fortes, ce qui ne se fait pas facilement. Premièrement, toute donnée chargée dans un cache doit aussi l'être dans les caches de niveau inférieur. Ensuite, quand une donnée est présente dans un cache, elle doit être maintenue dans les niveaux de cache inférieurs. De plus, toute donnée effacée d'un cache doit être effacée des niveaux de cache supérieurs : si une donnée quitte le cache L2, elle doit être effacée du L1. Ces trois contraintes posent des problèmes si chaque cache décide du remplacement des lignes de cache en utilisant un algorithme comme LRU, LFU, MRU, ou autre, qui utilise l'historique des accès. En effet, dans ce cas, le cache décide de remplacer les lignes de cache selon l'historique des accès, historique qui varie suivant chaque niveau de cache. Par exemple, une donnée rarement utilisée dans le L2 peut parfaitement être très fréquemment utilisée dans le L1 : la donnée sera alors remplacée dans le L2, mais sera maintenue dans le L1. On observe aussi des problèmes quand il existe plusieurs caches à un seul niveau : chaque cache peut remplacer les lignes de cache d'une manière indépendante des autres caches du même niveau, donnant lieu au même type de problème. Pour maintenir l'inclusion, les caches doivent se transmettre des informations qui permettent de maintenir l'inclusion. Par exemple, les caches de niveaux inférieurs doivent prévenir les niveaux de cache supérieurs quand ils remplacent une ligne de cache. De plus, toute mise à jour dans un cache doit être répercutée dans les niveaux de cache inférieurs et/ou supérieurs. On doit donc transférer des informations de mise à jour entre les différents niveaux de cache. Généralement, le contenu des caches d'instruction n'est pas inclus dans les caches de niveau inférieurs, afin d'éviter que les instructions et les données se marchent sur les pieds. Enfin, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Dans ce cas, l'inclusion est plus difficile à maintenir, pour des raisons assez techniques. ===Les caches eDRAM, sur la carte mère et autres=== D'ordinaire, les mémoires caches sont intégrées au processeur, à savoir que cache et CPU sont dans le même circuit imprimé. Les caches sont donc fabriqués avec de la SRAM, seule forme de mémoire qu'on peut implémenter dans un circuit intégré. Intégrer tous les caches dans le processeur est une solution et efficace. Mais certains processeurs ont procédé autrement. [[File:Cache-on-a-stick module.jpg|vignette|Cache-on-a-stick module]] Des processeurs assez anciens incorporaient un cache L1 dans le processeur, mais plaçaient un cache L2 sur la carte mère. Le cache était clippé sur un connecteur sur la carte mère, un peu comme le sont les barrettes de mémoire. On parlait alors de '''''Cache on a stick''''' (COAST). On aurait pu s'attendre à ce que de tels caches soient en DRAM, vu qu'ils sont placés sur des barrettes de RAM, mais la ressemblance avec la mémoire RAM principale s'arrête là. Le cache était fabriqué en mémoire SRAM, même s'il est en théorie possible de faire de tels caches avec de la DRAM. Les premiers processeurs avec un cache faisaient ainsi, au début des années 90. Il a été introduiot sur les processeurs Motorola, et a été utilisé sur les IBM PC et les Macintosh de l'époque. Les ordinateurs Macintosh utilisaient de tels caches, pour la pluaprt des modèles. Pour ce qui est des PC, les premiers processeurs x86 faisaient pareil, notamment les processeurs Intel. Le 486, le Pentium et le Pentium 2 utilisaient des ''Cache on a stick''. L'avantage est que cela permettait de mettre plus de cache, à une époque où les circuits étaient limités en transistors. De plus, cela permettait au consommateur de choisir quelle quantité de cache il voulait, selon ses finances. Il était possible de laisser le processeur fonctionner soit sans mémoire cache, soit avec un cache de 256 Kibioctets, de 512 Kibioctets, etc. Il était possible d'upgrader le cache si besoin. Pour les CPU Intel, le cache était connecté sur le bus système, au même titre que la mémoire RAM et les entrées-sorties. Il faut dire que les processeurs de l'époque utilisaient un bus système et n'avaient pas de bus mémoire dédié. Mais en théorie, rien n’empêche de connecter le cache sur un bus mémoire dédié. Toujours est-il que les lectures et écritures étaient propagées à la fois dans le cache et la RAM. Les écritures se faisaient dans les deux, systématiquement dans la RAM, mais aussi dans le cache en cas de succès de cache. Les lectures étaient servies soit par le cache en cas de succès de cache, soit par la RAM en cas de défaut de cache. Si le cache répondait en premier, la transaction sur le bus se terminait précocement et l'accès en RAM était abandonné. [[File:Intel486 Иерархия памяти.png|centre|vignette|upright=2.5|Intel486 : le cache était connecté sur le bus système.]] À l'inverse, certains processeurs possédaient un cache fabriqué en mémoire DRAM, et plus précisément avec de la mémoire eDRAM. Le cache n'était pas intégré dans le même circuit imprimé que le processeur, mais profitait d'une architecture en ''chiplet''. Pour rappel, cela veut dire que le processeur est en réalité composé de plusieurs circuits intégré séparés, mais interconnectés et soudés sur un même PCB carré. Avec un cache en eDRAM, le cache avait son propre circuit intégré, séparé du circuit intégré du processeur ou du circuit intégré pour le contrôleur mémoire/IO. Un exemple est celui du cache des processeurs Intel de microarchitecture Broadwell, vus dans ce chapitre dans la section sur les caches splittés. Les tags étaient intégrés dans le circuit intégré du processeur, mais les données étaient mémorisées dans une puce d'eDRAM séparée. La puce eDRAM correspondait en réalité à une DRAM adressable qui servait de DRAM pour les données et mémorisaient les voies du cache. ==Les caches adressés par somme et hashés== Les caches adressés par somme sont optimisés pour incorporer certains calculs d'adresse directement dans le cache lui-même. Pour rappel, certains modes d'adressage impliquent un calcul d'adresse, qui ajoute une constante à une adresse de base. Généralement, l'adresse de base est l'adresse d'un tableau ou d'une structure, et la constante ajoutée indique la position de la donnée dans le tableau/la structure. Les caches hashés et les caches adressés par somme permettent de faire l'addition directement dans la mémoire cache. Voyons d'abord les caches hashés, avant de passer aux caches adressés par somme. Sur les '''caches hashés''', l'addition est remplacée par une autre opération, par exemple des opérations bit à bit du style XOR, AND ou OR, etc. Seulement, utiliser des opérations bit à bit pose un problème : il arrive que deux couples Adresse/décalage donnent le même résultat. Par exemple, le couple Adresse/décalage 11101111/0001 donnera la même adresse que le couple 11110000/0000. Dit autrement, deux adresses censées être différentes (après application du décalage) sont en réalité attribuées à la même ligne de cache. Il est toutefois possible de gérer ces situations, mais cela demande des astuces de haute volée pour faire fonctionner la mémoire cache correctement. Sur les '''caches adressés par somme''', le décodeur est modifié pour se passer de l'addition. Pour comprendre comment, il faut rappeler qu'un décodeur normal est composé de comparateurs, qui vérifient si l'entrée est égale à une constante bien précise. Sur un cache ordinaire, l'addition est faite séparément du décodage des adresses par le cache, dans l'unité de calcul ou dans l'unité de génération d'adresse. [[File:Non sum adressed cache.png|centre|vignette|upright=2|Cache normal.]] Mais les caches adressés par somme modifient le décodeur, qui est alors composé de comparateurs qui testent si la somme adresse + décalage est égale à une constante. [[File:Cache adressé par somme.png|centre|vignette|upright=2|Cache adressé par somme.]] Chaque circuit du décodeur fait le test suivant, avec K une constante qui dépend du circuit : : <math>A + B = K</math> Ce qui est équivalent à faire le test suivant : : <math>A + B - K = 0</math> En complément à deux, on a <math>- K = \overline{K} + 1</math>. En injectant dans l'équation précédente, on a : : <math>A + B + \overline{K} + 1 = 0</math> En réorganisant les termes, on a : : <math>A + B + \overline{K} = - 1</math> Il suffit d'utiliser un additionneur ''carry-save'' pour faire l'addition des trois termes. Rappelons qu'un tel additionneur fournit deux résultats en sortie : une somme calculée sans propager les retenues et les retenues en question. Notons que les retenues sont à décaler d'un cran, vu qu'elles sont censées s'appliquer à la colonne suivante. En notant la somme S et les retenues R, on a: : <math>S + (R << 1) = - 1 </math>, le décalage d'un cran à gauche étant noté <math><< 1</math>. Ensuite, -1 est codé avec un nombre dont tous les bits sont à 1 en complément à un/deux. : <math>S + (R << 1) = 111 \cdots 111111</math> [[File:Sum + retenue add.png|centre|vignette|upright=2|Sum + retenue add]] Un simple raisonnement nous permet de savoir si le résultat est bien -1, sans faire l'addition <math>S + (R << 1)</math>. En effet, on ne peut obtenir -1 que si la somme est l'inverse des retenues : un 0 dans le premier nombre correspond à un 1 dans l'autre, et réciproquement. En clair, on doit avoir <math>\overline{S} = R << 1</math>. Pour vérifier cela, il suffit de faire un simple XOR entre la somme et les retenues décalées d'un cran. On a alors : : <math>S \oplus (R << 1) = 111 \cdots 111111</math> La comparaison avec -1 se fait avec une porte ET à plusieurs entrées. En effet, la porte donnera un 1 seulement si tous les bits d'entrée sont à 1, ce qui est ce qu'on veut tester. Au final, l'additionneur pour l'addition adresse + décalage est remplacé par un additionneur carry-save suivi d'une couche de portes XOR et d'un comparateur avec une constante, ce qui économise de circuits et améliore les performances. [[File:Final circuit of sum addressed cache.png|centre|vignette|upright=2|Cache adressé par somme.]] En prenant en compte que la constante K est justement une constante, certaines entrées de l'additionneur carry-save sont toujours à 0 ou à 1, ce qui permet quelques simplifications à grand coup d’algèbre de Boole. Chaque additionneur complet qui compose l’additionneur carry-save est remplacée par des demi-additionneurs (ou par un circuit similaire). Autant dire que l'on gagne tout de même un petit peu en rapidité, en supprimant une couche de portes logiques. Le circuit de décodage économise aussi des portes logiques, ce qui est appréciable. ==Les caches à accès uniforme et non-uniforme== Intuitivement, le temps d'accès au cache est le même pour toutes les lignes de cache. Il s'agit de cache appelés '''caches à accès uniforme''', sous-entendu à temps d'accès uniforme. Mais sur les caches de grande capacité, il arrive souvent que le temps de propagation des signaux varie fortement suivant la ligne de cache à lire. D'ordinaire, on se cale sur la ligne de cache la plus lente pour caler la fréquence d'horloge du cache, même si on pourrait faire mieux. Cependant, les '''caches à accès non uniforme''' ont une latence différente pour chaque ligne d'un même cache. Certaines lignes de cache sont plus rapides que d'autres. Niveau terminologie, nous allons parler de caches UCA et NUCA : ''Uniform Access Cache'' pour les caches à accès uniforme, ''Non-Uniform Access Cache'' pour les caches à accès non-uniforme. [[File:Caches UCA et NUCA.png|vignette|Caches UCA et NUCA.]] Les caches NUCA et UCA sont souvent composés de plusieurs banques séparées, typiquement une par voie. Sur les caches UCA, les banques sont interconnectées avec le processeur de manière à ce que toutes les interconnexions ont la même longueur pour toutes les banques. Typiquement, les banques sont organisées en carré, avec les interconnexions qui partent du centre, avec une disposition en H, illustrée ci-contre Mais avec les caches NUCA, ce n'est pas le cas. Les interconnexions sont simplifiées et ont des longueurs différentes. Les caches NUCA n'ont pas tous le même genre d'interconnexions, qui dépendent du cache NUCA. En général, les interconnexion forme un réseau avec des sortes de routeurs qui redirigent les données/commandes vers la bonne destination : cache ou processeur. Les banques plus proches du processeur sont accessibles plus rapidement que celles éloignées, même si la différence n'est pas énorme. Les caches NUCA sont généralement associatifs par voie. Les plus simples utilisent une banque par voie pour le cache, ce qui fait que certaines voies répondent plus vite que les autres. La détection des succès de cache est alors plus rapide si la donnée lue/écrite est dans une voie/banque rapide. En théorie, les défauts de cache demandent de vérifier toutes les banques, et se calent donc sur la pire latence. Mais divers caches se débrouillent pour que ce ne soit pas le cas, soit en vérifiant les banquyes unes par une, soit par un mécanisme de recherche plus complexe. Les caches NUCA sont surtout utilisés pour les caches L3 et L4, éventuellement les caches L2. Les caches L1 sont systématiquement des caches UCA, car la latence de l'accès au cache L1 est utilisée par le processeur pour décider quand lancer les instructions. Pour simplifier, le processeur peut démarrer en avance une instruction avant qu'une opérande soit lue dans le cache L1, de manière à ce que la donnée arrive en entrée de l'ALU pile en même temps que l'instruction. Une histoire d'exécution dans le désordre et d'émission anticipée des instructions qu'on détaillera dans une bonne dizaine de chapitres. Toujours est-il que tout est plus simple pour le processeur si le cache L1 a un temps d'accès fixe. Par contre, les caches L3 et L4 sont traités en attendant que les données arrivent, le processeur reprend l'exécution des instructions quand les caches L3 et L4 ont terminé de répondre, pas avant. Avec l'association une banque = une voie, la correspondance ligne de cache → bloc de mémoire qui est statique : on ne peut pas déplacer le contenu d'une ligne de cache dans une autre portion de mémoire plus rapide suivant les besoins. Mais la recherche académique a étudié le cas où la correspondance entre une ligne de cache et une banque varie à l’exécution. Pour nommer cette distinction, on parle de caches S-NUCA (''Static NUCA'') et D-NUCA (''Dynamic NUCA''). Intuitivement, on s'attend à ce que les caches D-NUCA soient plus performants que les caches S-NUCA. Les lignes de cache les plus utilisées peuvent migrer dans une banque rapide, alors que les lignes de cache moins utilisées vont dans une banque éloignée. Les lignes de cache se répartissent dans le cache dynamiquement dans les banques où elles sont le plus adaptées. Mais paradoxalement, le gain des caches D-NUCA est presque nul, voire insignifiant. La raison est que les caches D-NUCA doivent incorporer un système pour déterminer dans quelle banque se situe la donnée pour détecter les succès/défauts de cache, ainsi qu'un système pour migrer les données entre banques. Et ce système augmente le temps d'accès au cache, réduisant à néant l'intérêt d'un cache D-NUCA. Si on économise quelques microsecondes de temps d'accès en passant d'un cache UCA à un cache S-NUCA, ce n'est pas pour les perdre en passant à un D-NUCA. La majorité des caches D-NUCA sont donc en cours de recherche, mais ne sont pas utilisés en pratique. ==La tolérance aux erreurs des caches== Une mémoire cache reste avant tout une mémoire RAM, bien que ce soit de la SRAM. Elle n'est pas parfaite et est donc sujette à des erreurs, qui peuvent inverser un bit ou l'effacer. De telles erreurs sont liées à des rayons cosmiques très énergétiques, à des particules alpha produites par le packaging ou le métal deu circuit intégré, peu importe : l'essentiel est qu'ils inversent parfois un bit. Les mémoires modernes savent se protéger contre de telles erreurs, en utilisant trois moyens. ===Les mémoires caches ECC et à bit de parité=== Le premier moyen est l'usage de codes correcteurs d'erreurs, qui ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Les bits ajoutés dépendent de la donnée mémorisée dans le byte, et servent à détecter une erreur, éventuellement à la corriger. Le cas le plus simple ajoute un simple bit de parité pour chaque byte et se contente de détecter les erreurs dans les corriger. Les autres codes ECC permettent eux de corriger des erreurs, mais ils demandent d'ajouter au moins deux bits par byte, ce qui a un cout en circuit plus élevé. Un simple bit de parité permet de détecter qu'un bit a été inversé, mais ne permet pas de corriger l'erreur. En soi, ce n'est pas un problème. Si une erreur est détectée, on considère que la ligne de cache est invalide. Le cache gère la situation comme un défaut de cache et va chercher la donnée valide en mémoire RAM. Le cout en circuits est donc faible, mais les défauts de cache sont plus nombreux. Les codes ECC sont eux capables de corriger les erreurs, si elles ne modifient pas trop de bits d'un coup. Par contre, ils utilisent deux à trois bits par octet, ce qui a un cout en circuits loin d'être négligeable. Il y a donc un compromis entre défauts de cache et cout en circuits. La gestion de l'ECC est différente suivant le niveau de cache. Généralement, le cache L1 n'utilise pas l'ECC mais se contente d'un simple bit de parité pour éviter la corruption de ses données. Le cache étant petit, les corruptions de données sont assez rares, et les défauts de cache induits faibles. Il est plus important d'utiliser un code de détection d'erreur simple, rapide, qui ne ralentit pas le cache et n'augmente pas sa latence. Si une ligne de cache est corrompue, il a juste à aller lire la ligne depuis le cache L2, ou un niveau de cache inférieur. Du moins, c'est possible sur le cache en question est un cache inclusif et/ou ''write-through''. Par contre, le niveau de cache L2 et ceux en-dessous utilisent presque systématiquement une mémoire SRAM ECC. La raison principale étant que ce sont des caches assez gros, pour lesquels la probabilité d'une erreur est assez élevée. Plus une mémoire a de bits et prend de la place, plus il y a une chance élevée qu'un bit s'inverse. Et vu que les caches L2/L3/L4 sont par nature plus lents et plus gros, ils peuvent se permettre le cout en performance lié à l'ECC, idem pour le cout en circuit. Sans compter qu'en cas d'erreur, ils doivent aller lire la ligne de cache originelle en mémoire RAM, ce qui est très lent ! Mieux vaut corriger l'erreur sur place en utilisant l'ECC. ===L'usage du ''memory scrubbing'' sur les caches=== La plupart des erreurs ne changent qu'un seul bit dans un byte, mais le problème est que ces erreurs s'accumulent. Entre deux accès à une ligne de cache, il se peut que plusieurs erreurs se soient accumulées, ce qui dépasse les capacités de correction de l'ECC. Dans ce cas, il existe une solution appelée le ''memory scrubbing'', qui permet de résoudre le problème au prix d'un certain cout en performance. Pour rappel, l'idée est de vérifier les lignes de caches régulièrement, pour éviter que les erreurs s'accumulent. Par exemple, on peut vérifier chaque ligne de cache toutes les N millisecondes, et corriger une éventuelle erreur lors de cette vérification. En faisant des vérifications régulières, on garantir que les erreurs n'ont pas le temps de s'accumuler, sauf en cas de malchance avec des erreurs très proches dans le temps. Il ne s'agit pas d'un rafraichissement mémoire, car les SRAM ne s'effacent pas), mais ça a un effet similaire. Et évidemment, le ''memory scrubbing'' a un cout en performance. On peut faire une comparaison avec le rafraichissement mémoire : les rafraichissement réguliers réduisent les performances, car cela fait des accès en plus. Des accès qui sont de plus timés à des instants bien précis qui ne sont pas forcément les plus adéquats. Il est possible qu'un rafraichissement ait lieu en même temps qu'un accès mémoire et le rafraichissement a la priorité, ce qui réduit les performances. La même chose arrive avec les vérifications du ''memory scrubbing''. Malgré tout, la technique a été utilisée sur les caches de certains processeurs commerciaux, dont des processeurs AMD Athlon et Athlon 64. Elle est surtout utilisable sur les caches L2/L3, pour lesquels le cout du pseudo-rafraichissement est acceptable. ==Un exemple de cache : le cache d'instruction== La grande majorité des processeurs utilise deux caches L1 séparés : un '''cache d'instructions''' dédié aux instructions, et un autre pour les données. Une telle organisation permet de charger une instruction tout en lisant une donnée en même temps. Notons que seul le cache L1 est ainsi séparé entre cache de données et d'instructions. Le cache d’instruction se situe en théorie entre l'unité de chargement et l'unité de décodage. En effet, ce cache prend en entrée une adresse et fournit une instruction. L'adresse est fournie par le ''program counter'', l'instruction est envoyée dans l'unité de décodage. Le cache se situe donc entre les deux. Le cache de données L1 est connecté au chemin de données, et notamment aux unités de communication avec la mémoire, pas au séquenceur. [[File:Caches L1 et positions dans le processeur.png|centre|vignette|upright=2.5|Caches L1 et positions dans le processeur]] Les deux caches sont reliés au processeur par des bus séparés, l'ensemble ressemble à une architecture Harvard, mais où les caches remplacent les mémoires RAM/ROM. Le cache d'instruction prend la place de la mémoire ROM et le cache de données prend la place de la mémoire RAM. Évidemment, il y a des niveaux de caches en dessous des caches de données/instruction, et ceux-ci contiennent à la fois données et instructions, les deux ne sont pas séparées dans des mémoires/caches séparés. Raison pour laquelle l'ensemble est appelé une '''architecture Harvard modifiée'''. Architecture Harvard, car l'accès aux données et instructions se font par des voies séparées pour le processeur, modifiée car la séparation n'est effective que pour le cache L1 et pas les autres niveaux de cache, et encore moins la RAM. Sur les processeurs modernes, il arrive très souvent que le processeur doive charger une instruction et lire/écrire une donnée en même temps. Et à vrai dire, c'est la règle plus que l'exception. L'usage d'une architecture Harvard modifiée permet cela très facilement : on peut accéder au cache d'instruction via un bus, et au cache de donnée avec l'autre ===Pourquoi scinder le cache L1 en cache d'instruction et de données=== L'usage d'un cache d’instruction séparé du cache de données est à contraster avec l'usage d'un cache L1 multiport unique, capable de mémoriser à la fois instructions et données. Les deux solutions sont possibles ont été utilisées. Les premiers processeurs avaient un cache L1 unique et multiport, mais ce n'est plus le cas sur les processeurs modernes, car les contraintes ne sont pas les mêmes. Le compromis à faire est celui entre deux petits caches rapides et un gros cache plus lent. Pour rappel, plus un cache est petit, plus il est rapide et chauffe moins. Donc au lieu d'utiliser, par exemple, un gros cache lent de 64 Kibioctets, on utilise deux caches de 32 kibioctets, plus rapides. La capacité totale est la même, mais le temps d'accès plus faible. En termes de temps d'accès, la meilleure solution est celle des deux caches simple port. Mais pour ce qui est de l'économie de circuits, c'est moins évident. Entre deux mémoires simple port et une mémoire multiport, la différence en termes de transistors est ambigüe et dépend de la capacité des caches. La différence est surtout notable pour les gros caches, moins pour les petits caches. Il faut aussi tenir compte de la capacité effective. Avec deux caches séparés, la répartition de la capacité du cache L1 est fixée une bonne fois pour toutes. Par exemple, avec un cache d'instruction de 32 KB et un cache de données de 32 KB, impossible d'allouer 40 KB aux données et 20 aux instructions. Alors qu'avec un cache L1 unique de 64 KB, on pourrait le faire sans soucis. La répartition se fait naturellement, en fonction de la politique de remplacement du cache et est proche de l'optimal. C'est là un désavantage des caches d'instructions/données séparés : une capacité effective moindre. Tout cela explique pourquoi le cache L1 est le seul à être ainsi scindé en deux, avec une séparation entre instructions et données : les contraintes au niveau du cache L1 et L2 ne sont pas les mêmes. Pour les caches L1, le temps d'accès est plus important que la capacité, ce qui favorise les caches séparés. Par contre, pour les caches L2/L3/L4, le temps d'accès n'est pas déterminant, alors que la capacité effective et l'économie en circuits sont significatives. ===La connexion des caches L1 avec le cache L2=== Pour les connexions avec le cache L2, tout dépend du processeur. Certains utilisent un cache L2 multiport, qui permet aux deux caches L1 de lire ou écrire dans le cache L2 simultanément. [[File:Cache d'instructions.png|centre|vignette|upright=1.5|Cache d'instructions.]] Si le cache L2 ne gère pas les accès simultanés, il n'y a qu'un seul bus relié aux caches L1 et au cache L2. On doit effectuer un arbitrage pour décider quel cache a la priorité, chose qui est réalisé par un circuit d'arbitrage spécialisé. [[File:Circuit d'arbitrage du cache.png|centre|vignette|upright=1.5|Circuit d'arbitrage du cache.]] Généralement, les caches d'instructions peuvent se permettre d'être plus petits que les caches de données, car les programmes sont souvent plus petits que les données manipulées. Songez que des programmes de quelques mébioctets peuvent parfois remplir la RAM avec plusieurs gibioctets de données. Lancez votre navigateur internet et ouvrez une page web un peu chargée, pour vous en convaincre ! ===Les spécificités du cache d'instruction : lecture seule, bloquant, etc=== Les instructions sont rarement modifiées ou accédées en écritures, contrairement aux données. Et cela permet d'utiliser un cache simplifié pour les instructions. Autant un cache généraliste doit permettre les lectures et écritures depuis le processeur (avec les échanges avec la RAM), autant un cache d'instruction peut se contenter des lectures provenant du CPU et des échanges avec la RAM. Le cache d'instructions est donc très souvent en « lecture seule » : le processeur ne peut pas écrire dedans, mais juste le lire ou charger des instructions dedans. Un cache d'instruction est donc plus simple qu'un cache pour les données : on peut retirer les circuits en charge de l'écriture (mais on doit laisser un port d'écriture pour charger les instructions dedans). Le gain en circuits permet d'utiliser un cache d'instruction plus gros ou au contraire de laisser de la place pour le cache de données. Le gain en termes de capacité compense alors un peu les inconvénients des caches séparés. Par contre, cela complique la gestion du code automodifiant, c'est-à-dire des programmes dont certaines instructions vont aller en modifier d'autres, ce qui sert pour faire de l'optimisation ou est utilisé pour compresser ou cacher un programme (les virus informatiques utilisent beaucoup de genre de procédés). Quand le processeur exécute ce genre de code, il ne peut pas écrire dans ce cache L1 d'instructions, mais doit écrire dans le cache L2 ou en RAM, avant de recharger les instructions modifiées dans le cache L1. Cela qui prend du temps et peut parfois donner lieu à des erreurs si le cache L1 n'est pas mis à jour. Les algorithmes de remplacement des lignes de cache optimaux pour les données ne le sont pas pour les instructions, de même que la taille optimale du cache, la taille des lignes de cache optimale, ou même les algorithmes de préchargement. Par exemple, pour le remplacement des lignes de cache, un simple algorithme LRU est presque optimal pour les instructions, autant il peut donner de mauvaises performances quand on manipule beaucoup de tableaux. Cela justifie d'utiliser des caches spécialisés pour chacune. On peut adapter le cache d'instruction à son contenu, ce qui le rend plus rapide ou plus petit à performance égale. Les caches d'instructions sont généralement des caches bloquants. Il ne servirait à rien de rendre un cache d'instruction non-bloquant, le cout en circuits ne se traduirait pas par une augmentation significative des performances. À l'opposé, les caches de données sont non-bloquants sur les architectures modernes, pour des raisons de performance. Ce qui rend la séparation assez intéressante, les deux caches ayant des besoins différents et des implémentations différentes, cela permet d'optimiser le cout en transistors des caches. ===L'impact du cache d'instruction sur les performances=== Sur les architectures conventionnelles, le cache d'instruction a plus d'impact sur les performances que le cache de données. La raison principale est que les instructions ont une meilleure localité spatiale et temporelle que pour les données. Pour la localité spatiale, les instructions consécutives se suivent en mémoire, alors que rien ne garantit que des données utilisées ensemble soient regroupées en mémoire. Pour localité temporelle, elle est très variable pour les données, mais très courante pour les instructions du fait de l'usage fréquent des boucles et des fonctions. : La présence de branchements atténue la localité temporelle des instruction, sauf que la majorité des branchements sautent à un endroit très proche, seuls les appels de fonction brisent la localité spatiale. La conséquence est qu'il arrive que certains CPU aient un cache L1 d'instruction plus gros que celui pour les données. On parle alors de '''cache L1 asymétriques'''. Un exemple est celui des processeurs AMD de microarchitecture Zen, dont le cache d'instruction était deux fois plus gros que le cache de données. Leur cache d'instruction faisait 64 kibioctets, contre seulement 32 pour le cache de données. D'ailleurs, il existe des processeurs assez extrêmes qui se contentent d'un cache d'instruction unique, sans cache de données. C'est le cas sur les processeurs vectoriels ou les GPU que nous verrons dans les chapitres de fin de ce wikilivres. De tels processeurs sont spécialisés dans la manipulation de tableaux de données, traitement qui a une faible localité temporelle. En conséquence, utiliser un cache de données n'est pas vraiment utile, voire peu être contreproductif, alors qu'un cache d’instruction fonctionne parfaitement. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Compléments sur les mémoires de masse | prevText=Compléments sur les mémoires de masse | next=Le préchargement | nextText=Le préchargement }} </noinclude> nxb0ldwaqxjbayx5bv6z6qocwdsxf7n 765117 765116 2026-04-26T14:58:51Z Mewtow 31375 /* L'exemple des processeurs 386 et du contrôleur de cache 82385 */ 765117 wikitext text/x-wiki Le cache est une mémoire intercalée entre la mémoire et un processeur, plus rarement à l'intérieur d'un périphérique. Il est souvent fabriquée avec de la mémoire SRAM, parfois avec de l'eDRAM. Sans lui, on se croirait à l'âge de pierre tellement nos PC seraient lents ! En effet, la mémoire est très lente comparée au processeur. Le temps mis pour accéder à la mémoire est du temps durant lequel le processeur n'exécute pas d'instruction (sauf cas particuliers impliquant un pipeline). Pour diminuer ce temps d'attente, il a été décidé d'intercaler une mémoire petite mais rapide, entre le processeur et la mémoire. Ainsi, le processeur accède à un cache très rapide plutôt qu'à une RAM beaucoup plus lente. ==L'accès au cache== Le cache contient une copie de certaines données présentes en RAM. La copie présente dans le cache est accessible bien plus rapidement que celle en RAM, vu que le cache est plus rapide. Mais seule une petite partie de ces données sont copiées dans le cache, les autres données devant être lues ou écrites dans la RAM. Toujours est-il que le cache contient une copie des dernières données accédées par le processeur. Une donnée est copiée dans la mémoire cache quand elle est lue ou écrite par le processeur. Le processeur conserve une copie de la donnée dans le cache après son premier accès. Les lectures/écritures suivantes se feront alors directement dans le cache. Évidemment, au fur et à mesure des accès, certaines données anciennes sont éliminées du cache pour faire de la place aux nouveaux entrants, comme nous le verrons plus tard. [[File:Principe d'une mémoire cache.gif|centre|vignette|upright=2|Principe d'une mémoire cache.]] La mémoire cache est invisible pour le programmeur, qui ne peut pas déceler celles-ci dans l'assembleur. Les accès mémoire se font de la même manière avec ou sans le cache. La raison à cela est que le cache intercepte les accès mémoire et y répond s'il en a la capacité. Par exemple, si le cache intercepte une lecture à une adresse et que le contenu de cette adresse est dans le cache, le cache va outrepasser la mémoire RAM et la donnée sera envoyée par le cache au lieu d'être lue en RAM. par contre, si un accès se fait à une adresse pour laquelle le cache n'a pas la donnée, alors l'accès mémoire sera effectué par la RAM de la même manière que si le cache n'était pas là. [[File:Accès au cache.png|centre|vignette|upright=2|Accès au cache]] ===Les succès et défauts de caches=== Tout accès mémoire est intercepté par le cache, qui vérifie si la donnée demandée est présente ou non dans le cache. Si la donnée voulue est présente dans le cache, on a un '''succès de cache''' (''cache hit'') et on accède à la donnée depuis le cache. Sinon, c'est un '''défaut de cache''' (''cache miss'') et on est obligé d’accéder à la RAM. Les défauts de cache peuvent avoir plusieurs origines. Tout ce qu'il faut savoir est que lorsque le processeur accède à une donnée ou une instruction pour la première fois, il la place dans la mémoire cache car elle a de bonnes chances d'être réutilisée prochainement. La raison à cela est qu'un programme a tendance à réutiliser les instructions et données qui ont été accédées dans le passé : c'est le ''principe de localité temporelle''. Bien évidement, cela dépend du programme, de la façon dont celui-ci est programmé et accède à ses données et du traitement qu'il fait, mais c'est souvent vrai en général. La première cause des défauts de cache est liée à la taille du cache. À force de charger des données/instructions dans le cache, le cache fini par être trop petit pour conserver les anciennes données. Le cache doit bien finir par faire de la place en supprimant les anciennes données, qui ont peu de chances d'être réutilisées. Ces anciennes données éliminées du cache peuvent cependant être accédées plus tard. Tout prochain accès à cette donnée mènera à un cache miss. C'est ce qu'on appelle un ''Capacity Cache Miss'', ou encore '''défaut de capacité'''. Les seules solutions pour éviter cela consistent à augmenter la taille du cache ou à optimiser le programme exécuté (voir plus bas). Une autre raison pour un défaut est donc la suivante. Lorsqu'on exécute à une instruction ou qu'on accède à donnée pour la première fois, celle-ci n'a pas encore été chargée dans le cache. Le défaut de cache est inévitable : ce genre de cache miss s'appelle un ''Cold Miss'', ou encore un '''défaut à froid'''. De tels défauts sont presque impossibles à éliminer, sauf à utiliser des techniques de préchargement qui chargent à l'avance des données potentiellement utiles. Ces méthodes de préchargement se basent sur le principe de localité spatiale, à savoir le fait que les programmes ont tendance à accéder à des données proches en mémoire. Pour donner un exemple, les instructions d'un programme sont placées en mémoire dans l’ordre dans lequel on les exécute : la prochaine instruction à exécuter est souvent placée juste après l'instruction en cours (sauf avec les branchements). Quand on accède à une donnée ou une instruction, le cache peut précharger les données adjacentes pour en profiter. Nous parlerons de ces techniques de préchargement dans un chapitre dédié, vers la fin du cours. ===Le fonctionnement du cache, vu du processeur=== Vu du processeur, le cache prend en entrée toutes les informations nécessaires pour effectuer un accès mémoire : des signaux de commande, une adresse et la donnée à écrire si besoin. Tout cela est passé en entrée du cache, celui-ci répondant aux accès mémoire via divers bits de contrôles, que le processeur peut lire à souhait. Le cache fournit aussi la donnée à lire, pour les lectures, sur une sortie, connectée directement au bus mémoire/processeur. Globalement, le cache a une capacité limitée, mais il prend en entrée des adresses complètes. Par exemple, sur un processeur 64 bits, le cache prend en entrée des adresses de 64 bits (sauf si optimisations), même si le cache en question ne fait que quelques mébioctets. Les caches sont souvent des mémoires multiports, surtout sur les processeurs récents. Les caches simple port sont rares, mêmes s'ils existent et ont existé par le passé. les caches double port sont eux plus fréquents, et ont généralement un port d'écriture séparé du port de lecture. Mais les caches récents ont plusieurs ports de lecture/écriture et sont capables de gérer plusieurs accès mémoire simultanés. Les données présentes dans le cache sont (pré)chargées depuis la mémoire, ce qui fait que toute donnée dans le cache est la copie d'une donnée en mémoire RAM. Le cache doit faire la correspondance entre une donnée du cache et l'adresse mémoire correspondante. Du point de vue du fonctionnement, on peut voir le cache comme une sorte de table de correspondance, qui mémorise des données, chacune étant associée à son adresse mémoire. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Cela vaut du point de vue du processeur, le fonctionnement interne du cache étant quelque peu différent selon le cache. Il existe des caches dont le fonctionnement interne est bien celui d'une table de correspondance matérielle, d'autres qui sont beaucoup plus optimisés. [[File:Fonctionnement d'une mémoire associative à correspondance.png|centre|vignette|upright=2|Fonctionnement simplifié d'une mémoire cache : les adresses sont dans la colonne de gauche, les données sont dans la colonne de droite. On voit qu'on envoie l'adresse au cache, que celui-ci répond en renvoyant la donnée associée.]] ==La performance des mémoires caches== L'analyse de la performance des mémoires caches est plus riche pour celle des autres mémoires. Sa performance dépend de beaucoup de paramètres, mais on peut cependant citer les principaux. Les deux premiers sont tout bonnement sa latence et son débit, comme pour n'importe quelle autre mémoire. La latence est plus importante que son débit, car le processeur est généralement plus rapide que le cache et qu'il n'aime pas attendre. Mais le critère le plus important pour un cache est sa capacité à empêcher des accès mémoire, son efficacité. Plus les accès mémoire sont servis par le cache au lieu de la RAM, meilleures seront les performances. Pour résumer, la performance d'un cache est surtout caractérisée par deux métriques : le taux de défaut, qui correspond à l’efficacité du cache, et la latence du cache. ===Le taux de succès/défaut=== Le '''taux de succès''' (hit ratio) est un premier indicateur des performances du cache, mais un indicateur assez imparfait. C'est le pourcentage d'accès mémoire qui ne déclenchent pas de défaut de cache. Plus il est élevé, plus le processeur accède au cache à la place de la RAM et plus le cache est efficace. Certains chercheurs préfèrent utiliser le '''taux de défauts''', à savoir le pourcentage d'accès mémoire qui entraînent un défaut de cache. Plus il est bas, meilleures sont les performances. Le taux de défaut est relié au taux de succès par l'équation <math>T_\text{succes} = 1 - T_\text{defaut}</math>. Par définition, il est égal à : : <math>\text{Taux de défauts de cache} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d’accès mémoires}}</math> Plutôt que de comparer le nombre de défauts/succès de cache au nombre d'accès mémoire, il est aussi possible de diviser le nombre de défauts par le nombre total d'instructions. On obtient alors le '''taux de défauts/succès par instruction''', une autre métrique utile. Par définition, elle est égale à : : <math>\text{Taux de défauts par instruction} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d'instructions}} = \text{Taux de défauts de cache} \times \frac{\text{Nombre d’accès mémoires}}{\text{Nombre d'instructions}}</math> Si certains défauts de cache sont inévitables quel que soit le cache, comme les défauts à froids, mentionnés plus haut, d'autres défauts peuvent être évités en augmentant la capacité du cache. C'est le cas des défauts de capacité qui sont causés par un accès à une donnée qui a été éliminée du cache faute de place. Plus le cache est gros, moins il a de chances d'être rempli, moins il doit rapatrier de données, plus son taux de succès augmente. Mais nous reviendrons sur le lien entre taille du cache et taux de défaut plus bas. Le taux de succès ne dépend pas que du cache, mais aussi de la conception des programmes exécutés. Une bonne utilisation du cache (ainsi que de la mémoire virtuelle) repose sur le programmeur qui doit prendre en compte les principes de localités dès la conception de ses programmes. Par exemple, un programmeur peut parfaitement tenir compte du cache au niveau de son algorithme : on peut citer l'existence des algorithmes ''cache oblivious'', qui sont conçus pour être optimaux quelle que soit la taille du cache. Le programmeur peut aussi choisir ses structures de données de manière à améliorer la localité. Par exemple, un tableau est une structure de donnée respectant le principe de localité spatiale, tandis qu'une liste chaînée ou un arbre n'en sont pas (bien qu'on puisse les implémenter de façon à limiter la casse). D'autres optimisations sont parfois possibles : par exemple, le sens de parcours d'un tableau multidimensionnel peut faire une grosse différence. Cela permet des gains très intéressants pouvant se mesurer avec des nombres à deux ou trois chiffres. Je vous recommande, si vous êtes programmeur, de vous renseigner le plus possible sur les optimisations de code ou algorithmiques qui concernent le cache : il vous suffira de chercher sur Google. Il y a une citation qui résume bien cela, prononcée par un certain Terje Mathisen. Si vous ne le connaissez pas, cet homme est un vieux programmeur (du temps durant lequel on codait encore en assembleur), grand gourou de l’optimisation, qui a notamment travaillé sur le moteur de Quake 3 Arena. {{BlocCitation|Almost all programming can be viewed as an exercise in caching.|auteur=Terje Mathisen}} ===La latence moyenne d'un cache=== Le temps mis pour lire ou écrire une donnée varie en présence d'un cache. Certaines lectures/écritures vont atterrir directement dans le cache (succès) tandis que d'autres devront aller chercher leur contenu en mémoire RAM (défaut de cache). Dans tous les cas, qu'il y ait défaut ou non, le cache sera consulté et mettra un certain temps à répondre, égal au temps de latence du cache. Tous les accès mémoires auront donc une durée au moins égale au temps de latence du cache, qui sera notée <math>T_c</math>. En cas de succès, le cache aura effectué la lecture ou l'écriture, et aucune action supplémentaire n'est requise. Ce qui n'est pas le cas en cas de défaut : le processeur devra aller lire/écrire la donnée en RAM, ce qui prend un temps supplémentaire égal au temps de latence de la mémoire RAM. Un défaut ajoute donc un temps, une pénalité, à l'accès mémoire. Dans ce qui suivra, le temps d'accès à la RAM sera noté <math>T_m</math>. Fort de ces informations, nous pouvons calculer le temps de latence moyen d'un accès mémoire, qui est la somme du temps d'accès au cache (pour tous les accès mémoire), multiplié par le temps lié aux défauts. On a alors : : <math>T = T_c + \text{Taux de défaut} \times T_m</math> On voit que plus le taux de succès est élevé, plus le temps de latence moyen sera bas, et inversement. Ce qui explique l'influence du taux de succès sur les performances du cache, influence assez importante sur les processeurs actuels. De nos jours, le temps que passe le processeur dans les défauts de cache devient de plus en plus un problème au fil du temps, et gérer correctement le cache est une nécessité, particulièrement sur les processeurs multi-cœurs. Il faut dire que la différence de vitesse entre processeur et mémoire est tellement importante que les défauts de cache sont très lents : alors qu'un succès de cache va prendre entre 1 et 5 cycles d'horloge, un cache miss fera plus dans les 400-1000 cycles d'horloge. Tout ce temps sera du temps de perdu que le processeur aura du mal à mitiger. Autant dire que réduire les défauts de cache est beaucoup plus efficace que d'optimiser les calculs effectués par le processeur (erreur courante chez de nombreux programmeurs, notamment débutants). ===L'impact de la taille du cache sur le taux de défaut et la latence=== Il y a un lien entre taille du cache, taux de défaut, débit binaire et latence moyenne. Globalement, plus un cache est gros, plus il est lent. Simple application de la notion de hiérarchie mémoire vue il y a quelques chapitres. Les raisons à cela sont nombreuses, mais nous ne pouvons pas les aborder ici, car il faudrait que nous sachions comment fonctionne un cache et ce qu'il y a à l'intérieur, ce qui sera vu dans la suite du chapitre. Toujours est-il que la latence moyenne d'un cache assez gros est assez importante. De même, le débit binaire d'un cache diminue avec sa taille, mais dans une moindre mesure. Les petits caches ont donc un gros débit binaire et une faible latence, alors que c'est l'inverse pour les gros caches. Une grande capacité de cache améliore le taux de succès, mais cela se fait au détriment de son temps de latence et de son débit, ce qui fait qu'il y a un compromis assez difficile à trouver entre taille du cache, latence et débit. Il peut arriver qu'augmenter la taille du cache augmente son temps d'accès au point d’entraîner une baisse de performance. Par exemple, les processeurs Nehalem d'Intel ont vus leurs performances dans certains jeux vidéos baisser de 2 à 3 %, malgré de nombreuses améliorations architecturales, parce que la latence du cache L1 avait augmentée de 2 cycles d'horloge. Pour avoir une petite idée du compromis à faire, regardons la relation entre taille du cache et taux de défaut. Il existe une relation approximative entre ces deux variables, appelée la '''loi de puissance des défauts de cache'''. Elle donne le nombre total de défaut de cache en fonction de la taille du cache et de deux autres paramètres. Voici cette loi : : <math>\text{Taux de défauts de cache} \approx K \times \text{Taille du cache}^{- \alpha }</math>, avec <math>K</math> et <math>\alpha</math> deux coefficients qui dépendent du programme exécuté. Le coefficient <math>\alpha</math> est généralement compris entre 0.3 et 0.7, guère plus, et varie suivant le programme exécuté. Précisons que cette loi ne marche que si le cache est assez petit par rapport aux données à utiliser. Pour un cache assez gros et des données très petites, la relation précédente est mise en défaut. Pour s'en rendre compte, il suffit d'étudier le cas extrême où toutes les données nécessaires tiennent dans le cache. Dans ce cas, il n'y a qu'un nombre fixe de défauts de cache : autant qu'il faut charger de données dans le cache. Le nombre de défauts de cache observé dans cette situation n'est autre que le coefficient <math>K</math> de la situation précédente, mais il n'y a aucune dépendance entre taux de défaut et taille du cache. L'origine de cette relation s'explique quand on regarde combien de fois chaque donnée est réutilisée lors de l’exécution d'un programme. La plupart des données finissent par être ré-accédées à un moment ou un autre et il se passe un certain temps entre deux accès à une même donnée. Sur la plupart des programmes, les observations montrent que beaucoup de réutilisations de données se font après un temps très court et qu'inversement, peu de ré-accès se font après un temps inter-accès long. Si on compte le nombre de réutilisation qui ont un temps inter-accès bien précis, on retrouve une loi de puissance identique à celle vue précédemment : : <math>\text{Nombre de réaccès avec un temps inter-accès égal à t} \approx K \times t^{- \beta}</math>, avec t le temps moyen entre deux réutilisations. Le coefficient <math>\beta</math> est ici compris entre 1.7 et 1.3. De manière générale, les coefficients <math>\alpha</math> et <math>\beta</math> sont reliés par la relation <math>\alpha = 1 - \beta</math>, ce qui montre qu'il y a un lien entre les deux relations. Précisons cependant que la loi de puissance précédente ne vaut pas pour tous les programmes informatiques, mais seulement pour la plupart d’entre eux. Il n'est pas rare de trouver quelques programmes pour lesquels les accès aux données sont relativement prédictibles et où une bonne optimisation du code fait que la loi de puissance précédente n'est pas valide. La loi de puissance des défauts de cache peut se démontrer à partir de la relation précédente, sous certaines hypothèses. Si un suppose que le cache est assez petit par rapport aux données, alors les deux relations sont équivalentes. L'idée qui se cache derrière la démonstration est que si le temps entre deux accès à une donnée est trop long, alors la donnée accédée aura plus de chance d'être rapatriée en RAM, ce qui cause un défaut de cache. La chance de rapatriement dépend de la taille du cache, un cache plus gros peut conserver plus de données et a donc un temps avant rapatriement plus long. ==Les lignes de cache et leurs tags== Du point de vue du processeur, les lectures et écritures se font mot mémoire par mot mémoire. Un processeur avec des entiers de 64 bits recoit des données de 64 bits de la part du cache, et y écrit des mots de 64 bits. Mais quand on regarde comment sont stockées les données à l'intérieur du cache, les choses sont différentes. ===Les lignes de cache=== Les données sont mémorisées dans le cache par blocs de plusieurs bytes, d'environ 64 à 256 octets chacun, qui portent le nom de '''lignes de cache'''. Les lignes de cache sont l'unité de stockage que l'on trouve à l'intérieur du cache, mais elles servent aussi d'unité de transaction avec la mémoire RAM. Sur les caches actuels, on transfère les données entre le cache et la RAM ligne de cache par ligne de cache, dans la limite de la taille du bus mémoire. Mais d'autres caches plus anciens permettaient de faire des transferts plus fins. C’est-à-dire qu'on pouvait mettre à jour quelques octets dans une ligne de cache sans avoir à la recopier intégralement depuis ou dans la mémoire RAM. En théorie, on pourrait imaginer des caches où les données sont stockées différemment, où l'unité serait le mot mémoire, par exemple. Par exemple, sur un processeur 64 bits, on aurait une ligne de cache de 64 bits. Cela aurait l'avantage de la simplicité : les transferts entre le processeur et la mémoire serait de même taille, l'intérieur du cache ressemblerait à son interface montrée au processeur. Mais cela aurait quelques défauts qui sont compensés par l'organisation en lignes de cache de grande taille. Le premier avantage des lignes de cache est lié à la localité spatiale, la tendance qu'on les programmes à accéder à des données proches les unes des autres. Des accès mémoires consécutifs ont tendance à se faire à des adresses proches, qui ont de bonnes chances d'être dans la même ligne de cache. Et des accès consécutifs à une même ligne de cache sont plus rapides que des accès à deux lignes distinctes. Une autre raison est tout simplement que cela simplifie considérablement la circuiterie du cache. Pour une capacité identique, il vaut mieux avoir peu de lignes de cache assez grosses, que beaucoup de petites lignes de cache. La raison est que les circuits du cache, comme le décodeur, l'encodeur et autres, ont moins de sorties et sont donc plus simples. ===L'alignement des lignes de cache=== Les lignes de cache sont des blocs de plusieurs dizaines à centaines de bytes, dont la taille est presque toujours une puissance de deux. De plus, les lignes de cache sont alignées en mémoire. Nous avions déjà abordé la notion d'alignement mémoire dans un chapitre précédent, mais le concept d'alignement des lignes de cache est quelque peu différent. Quand nous avions parlé d'alignement auparavant, il s'agissait de l'alignement des données manipulées par le processeur, qui faisait partie du jeu d'instruction du processeur. Ici, nous parlons d'un alignement totalement différent, invisible pour le programmeur, sans lien avec le jeu d’instruction. Voyons de quoi il retourne. Concrètement, cela veut dire que du point de vue du cache, la RAM est découpée en blocs qui font la même taille qu'une ligne de cache, aux positions prédéterminées, sans recouvrement entre les blocs. Par exemple, pour un cache dont les lignes de cache font 256 octets, le premier bloc est à l'adresse 0, le second est 256 octets plus loin, c'est à dire à l'adresse 256, le troisième à l'adresse 512, la quatrième à l'adresse 768, etc. Une ligne de cache de 256 octets contiendra une donnée provenant d'un bloc de RAM de 256 octets, dont l'adresse est systématiquement un multiple de 256. Il n'est pas possible qu'une ligne de cache contienne un bloc de 256 octets dont l'adresse du premier octet serait l'adresse 64, ou l'adresse 32, par exemple. En clair, les adresses de ces blocs sont des multiples de la taille de la ligne de cache, de la taille des blocs. Cela rappelle les contraintes d'alignement vues dans le chapitre "Le modèle mémoire : alignement et boutisme", mais appliquées aux lignes de cache. L'alignement des lignes de cache a des conséquences pratiques pour la conception des caches. Notons qu'il est en théorie possible d'avoir des caches dont les lignes de cache ne sont pas alignées, mais cela poserait des problèmes majeurs. Il serait en effet possible qu'une donnée soit présente dans deux lignes de cache à la fois. Par exemple, prenons le cas où une ligne de cache de 256 commence à l'adresse 64 et une autre ligne de cache commence à l'adresse 0. L'adresse 128 serait dans les deux lignes de cache ! Et cela poserait des problèmes lors des lectures, mais encore plus lors des écritures. C'est pour éviter ce genre de problèmes que les lignes de cache sont alignées avec la mémoire RAM dans tous les caches existants. L'alignement des lignes de cache est une chose que les programmeurs doivent parfois prendre en compte quand ils écrivent du code ultra-optimisé, destiné à des programmes demandant des performances extrêmes. Il arrive que les contraintes d'alignement posent des problèmes. Nous avions vu dans le chapitre sur le boutisme et l'alignement qu'il valait mieux gérer l'alignement des variables des structures de données, pour éviter les accès non-alignés avec le bus mémoire. La même chose est possible, mais pour l'alignement avec des lignes de cache. Typiquement, l'idéal est que, pour une structure de donnée, on puisse en mettre un nombre entier dans une ligne de cache. Ou alors, si la structure est vraiment grande, que celle-ci occupe un nombre entier de lignes de cache. Si ce n'est pas le cas, il y a un risque d'accès non-alignés, c'est à dire qu'une structure se retrouve à cheval sur deux lignes de cache, avec les défauts que cela implique. ===Le tag d'une ligne de cache=== Plus haut, nous avions dit que le cache mémorise, pour chaque ligne de cache, l'adresse RAM associée. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Mais du fait de l'organisation du cache en lignes de cache de grande taille, qui sont de plus alignées en mémoire, il faut nuancer cette affirmation. Le cache ne mémorise pas la totalité de l'adresse, ce qui serait inutile. L'alignement des lignes de cache en RAM fait que les bits de poids faible de l'adresse ne sont pas à prendre en compte pour l'association adresse-ligne de cache. Dans ces conditions, on mémorise seulement la partie utile de l'adresse mémoire correspondante, qui forme ce qu'on appelle le '''tag'''. Le reste de l'adresse indique quelle est la position de la donnée dans la ligne de cache. Par exemple, prenons le cas où le processeur gère des nombres entiers de 64 bits (8 octets) et des lignes de cache de 128 octets : chaque ligne de cache contient donc 16 entiers. Si le processeur veut lire ou écrire un entier bien précis, il doit préciser sa place dans la ligne de cache. Et ce sont les bits de l'adresse mémoire non-inclus dans le cache qui permettent de faire ça. En clair, une adresse mémoire à lire/écrire est interprété par le cache comme la concaténation d'un tag et de la position de la donnée dans la ligne de cache correspondante. [[File:Adressage d'un cache totalement associatif.png|centre|vignette|upright=2|Adressage d'un cache totalement associatif]] Le cache est donc une grande table de correspondance entre tags et lignes de cache. Lors d'un accès mémoire, le cache extrait le tag de l'adresse à lire ou écrire, et le compare avec les tags de chaque ligne de cache. Si une ligne contient ce tag, alors c'est que cette ligne correspond à l'adresse, et c'est un défaut de cache sinon. Lors d'un succès de cache, la ligne de cache est lue depuis le cache et envoyée à un multiplexeur qui sélectionne la donnée à lire dans la ligne de cache. Le fonctionnement est similaire pour une écriture : la donnée à écrire passe dans un démultiplexeur, qui envoie la donnée au bon endroit dans la ligne de cache sélectionnée. [[File:Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.png|centre|vignette|upright=2|Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.]] ===Le contenu d'une ligne de cache=== Dans ce qui va suivre, nous allons considérer que chaque ligne de cache mémorise son tag, les données de la ligne de cache proprement dit, et quelques bits de contrôle annexes qui varient suivant le cache considéré. [[File:Tag d'une ligne de cache.png|centre|vignette|upright=2|Tag d'une ligne de cache.]] Les caches modernes incluent de nombreux bits de contrôle, mais deux d'entre eux sont communs à presque tous les caches modernes : le bit ''Dirty'' et le bit ''Valid''. Le '''bit ''Valid''''' indique si la ligne de cache contient des données valides ou non. Si le bit ''Valid'' est à 0, la ligne de cache est en état valide, à savoir qu'elle contient des données et n'est pas vide. Par contre, si ce bit est à 1, la ligne de cache est invalide et son contenu ne peut pas être lu ou écrit. L'utilité de ce bit est qu'il permet d'effacer une ligne de cache très rapidement : il suffit de mettre ce bit à 0. Il existe des situations où le cache doit être effacé, on dit alors qu'il est invalidé. Une section de ce chapitre sera dédié à l'invalidation du cache. Le '''bit ''Dirty''''' indique qu'une ligne de cache a été modifiée. Par modifiée, on veut dire que le processeur a écrit dedans, qu'il a modifié la ligne de cache. Mais attention : si la donnée a été modifiée dans le cache, la modification n'est pas forcément propagée en mémoire RAM. Le bit ''dirty'' indique si c'est le cas, si l'écriture a été propagée en mémoire RAM. Il précise que la ligne de cache contient des données modifiées, alors que la RAM a des données initiales non-modifiées. Une ligne de cache avec un bit ''dirty'' à 1 est dite ''dirty'', par métonymie. Nous verrons cela en détail dans la section sur les caches ''write-back'' et ''write-through''. Les caches modernes ajoutent des '''bits de détection/correction d'erreur''' dans les bits de contrôle. Pour rappel, les codes de détection/correction d'erreur permettent de se prémunir contre des erreurs matérielles, qui corrompent les données stockées dans une mémoire, ici une mémoire cache. Ils ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Nous reviendrons dessus dans une section ultérieur de ce chapitre. Sur certains caches assez anciens, on pouvait transférer les lignes de caches morceaux par morceaux. Ces caches avaient des lignes de cache divisées en sous-secteurs, ces sous-secteurs étant des morceaux de ligne de cache qu'on pouvait charger indépendamment les uns des autres (mais qui sont consécutifs en RAM). Chaque secteur avait ses propres bits de contrôle, mais le tag était commun à tous les secteurs. [[File:Cache à secteurs.png|centre|vignette|upright=2.5|Cache à secteurs.]] : Dans ce qui va suivre, le terme "ligne de cache" désignera soit un bloc de données copiées depuis la RAM d'une taille de 64/128/256/... octets, soit la concaténation de ces données avec le tag et des bits de contrôle. Les deux définitions ne sont pas équivalentes, mais l'usage a entériné cet abus de langage. Et il faut avouer que cela rend les explications du chapitre plus simples. ==Les instructions de contrôle du cache== Plus haut, nous avions dit que le cache est totalement transparent du point de vue du programmeur. Le cache contient des copies de données en RAM, le programmeur n'a rien à faire pour utiliser le cache correctement. Mais la réalité est que pour des raisons diverses, des processeurs incorporent des '''instructions de contrôle du cache'''. Il s'agit d’instructions qui agissent sur le contenu du cache. Elles existent pour des raisons diverses qu'on détaillera plus bas, mais il s'agit globalement d'une question de performances ou de nécessité pour le système d'exploitation. ===Les instructions de préchargement=== La première instruction de contrôle du cache est une '''instruction de préchargement''', qui demande à charger un bloc de données dans le cache. Elle prend en opérande une adresse mémoire, et le contenu de cette adresse est chargé dans une ligne de cache. Bien sûr, des contraintes d'alignement sont à prendre en compte : on charge un bloc de la même taille qu'une ligne de cache, aligné en mémoire sur la taille du bloc, qui contient l'adresse. L'instruction de préchargement n'est utile que si l'instruction est exécutée bien avant que la donnée ne soit utilisée/lue/écrite. Cela permet de charger une donnée dans le cache à l'avance, d'où le nom de préchargement donné à cette technique. Mais les processeurs modernes gérent des techniques de préchargement automatique, qui ne requièrent pas d'instructions de préchargement. Le préchargement automatique et les instructions de préchargement sont deux solutions complémentaires, mais qui peuvent se marcher sur les pieds. Nous en reparlerons dans le prochain chapitre, qui sera dédié au préchargement automatique. Il faut noter que les instructions de préchargement peuvent être ignorées par le processeur. Sous certaines conditions, le processeur peut décider que l'instruction de préchargement ne sera pas exécutée. Par exemple, il ne va pas précharger une donnée déjà présente dans le cache. Ou encore, si le bus mémoire est occupé, il ne va pas exécuter le préchargement, par manque de ressources matérielles. ===Les instructions d'invalidation et de ''flush''=== Les instructions ''flush'' regroupent deux types d'instructions qui sont souvent utilisées en même temps. Il s'agit des instructions d'invalidation et de nettoyage (''clean''). Les deux termes proviennent de la terminologie ARM, il n'y a pas de terminologie standardisé pour les noms de ces instructions. Dans les grandes lignes, elles permettent de vider le cache, à savoir de rapatrier son contenu en RAM et de réinitialiser le cache à zéro. Elles sont utilisées par le système d'exploitation lors des commutations de contexte, à savoir quand on passe d'un programme à un autre. Elles sont aussi utilisées lors des appels systèmes et routines d'interruption/exception. L'idée est de vider le cache avant d'exécuter un nouveau programme ou une nouvelle routine. Le nouveau programme aura accès à un cache tout propre, les données de l'ancien programme auront été retirée du cache. Les '''instructions ''clean''''' recopient le contenu de la ligne de cache en RAM. Elles forcent la recopie immédiatement de la ligne de cache en mémoire RAM. Pour faire leur travail, elle vérifient si la ligne de cache a été modifiée, avant de la recopier en RAM. Et pour cela, ils vérifient le bit de contrôle ''dirty'', qui est mis à 1 après une première écriture. Si ce bit est à 0, alors pas besoin de recopier la ligne de cache : elle n'a pas été modifiée, la RAM a déjà la bonne copie. Mais s'il est à 1, le cache et la RAM n'ont pas le même contenu, la recopie s'exécute. Les '''instructions d'invalidation''' permettent d'invalider une ligne de cache, à savoir d'effacer son contenu. Nous verrons à quoi servent ces instructions dans la section sur les changement de processus. Invalider une ligne de cache est une opération optimisée : le cache n'est en réalité pas réellement effacé. À la place, le bit ''Valid'' de chaque ligne de cache est juste mis à 0. Il faut noter que l'invalidation efface les lignes de cache sans se préoccuper de leur contenu. Elle se moque qu'une ligne de cache contienne une donnée modifiée, ''dirty'' ou quoique ce soit : la ligne de cache est effacée, point. Il est possible d'invalider une ligne de cache en fournissant une adresse mémoire, mais il est aussi possible d'invalider le cache tout entier. Le choix entre les deux dépend du mode d'adressage de l'instruction d'invalidation. Parfois, il existe une instruction séparée pour invalider tout le cache, et une autre pour invalider une ligne de cache bien précise. Des instructions séparées sont parfois disponibles pour invalider les caches de données et d'instructions, parfois aussi la TLB (un cache qu'on verra dans quelques chapitres). Il est possible de n'invalider que le cache L1, voire le cache L2. Il faut noter que l'invalidation efface tout le cache, mais ne se préoccupe pas de vérifier si les données ont été modifiées dans le cache. Pour certains caches, comme le cache d'instruction, ce n'est pas un problème, vu qu'il est en "lecture seule". Mais pour les caches de données, les données modifiées sont perdues en cas d'invalidation. Heureusement, il existe des instructions d'invalidation qui fusionnent une instruction ''clean'' et une instruction d'invalidation. Il s'agit d''''instructions d'invalidation spéciales'''. ===Les instructions d'optimisation : instructions non-temporelles et écritures optimisées=== Les '''instructions mémoire non-temporelles''' contournent complètement le cache. Par exemple, une lecture peut lire une donnée, mais celle-ci ne sera pas chargée dans le cache, elle passe directement de la RAM vers les registres. Une section entière de ce chapitre sera dédiée au contournement du cache, à savoir aux situations où les accès mémoire doivent passer directement du processeur à la RAM sans passer par le cache. D'autres instructions assez rares incorporent des indications pour le cache. Par exemple, l'instruction ''load last'' des processeurs POWER PC implique que la donnée ne sera utilisée qu'une seule fois. Elle est donc chargée dans le cache, mais la ligne de cache est configurée de manière à être remplacée très rapidement, typiquement avec une valeur de LRU/LFU adéquate. La donnée est bien chargée dans le cache, au cas où elle doive être relue suite à une mauvaise prédiction de branchement ou autre, chose qu'une lecture non-temporelle (qui contourne le cache) ne fait pas. Des indications de ce type sont appelées des '''''cache hint'''''. L''''instruction ''flush''''' permet de préciser qu'une ligne de cache contient une donnée inutile, qui ne sera pas réutilisée par le programme. Pas besoin de la conserver dans le cache, elle peut laisser sa place à des données plus utiles. Or, sans indication, les algorithmes de remplacement d'une ligne de cache risquent de conserver cette donnée trop longtemps, ce qui entraine une certaine pollution du cache par des données inutiles. Une autre instruction est elle beaucoup plus importante : celle de '''pré-allocation sur écriture'''. Elle sert dans le cas où une ligne de cache est complétement écrite. Par exemple, imaginons qu'on veuille écrire dans une portion de mémoire. Si celle-ci n'est pas dans le cache, le processeur va charger une ligne de cache complète depuis la RAM, écrire dans la ligne de cache, puis recopier la ligne de cache modifiée en mémoire RAM. Une écriture en RAM demande donc de faire une lecture et une écriture. Mais les instructions de pré-allocation sur écriture permettent de prévenir qu'une ligne de cache sera intégralement écrite, et qu'il n'y a donc pas besoin de lire celle-ci depuis la RAM. Notons que l'instruction d'écriture qui suit n'est pas une écriture non-temporelle, vu que les données sont écrites dans la ligne de cache, qui est ensuite envoyée en mémoire RAM dès que nécessaire. De plus, les données écrites peuvent ensuite être relue depuis le cache si nécessaire. Enfin, certains processeurs MIPS incorporent une instruction pour modifier le tag d'une ligne de cache. Elles servent à optimiser les copies mémoire, à savoir quand on copie un bloc de données d'un endroit à un autre. L'idée est de charger le bloc de données dans le cache avec une instruction LOAD/PREFETCH, de modifier le tag pour qu'il pointe vers l'adresse à écrire, et de laisser faire le cache pour que l'écriture se fasse en RAM. Mais les contraintes pour utiliser cette instruction sont assez drastiques : les données doivent être alignées sur la taille d'une ligne de cache, le bloc de départ et d'arrivée (l'original versus la copie) ne doivent pas se recouvrir, etc. ==L'associativité des caches et leur adressage implicite== Lorsqu'on souhaite accéder au cache, il faut trouver quelle est la ligne de cache dont le tag correspond à l'adresse demandée. On peut classifier les caches selon leur stratégie de recherche de la ligne correspondante en trois types de caches : totalement associatifs, directement adressés (''direct mapped'') et associatifs par voie. ===Les caches totalement associatifs=== Avec les caches totalement associatifs, toute donnée chargée depuis la mémoire peut être placée dans n'importe quelle ligne de cache, sans aucune restriction. Ces caches ont un taux de succès très élevé, quand on les compare aux autres caches. [[File:Cache totalement associatif.png|centre|vignette|upright=2|Cache totalement associatif.]] Concevoir un cache totalement associatif peut se faire de deux grandes manières différentes. La première consiste tout simplement à combiner une mémoire associative avec une mémoire RAM, en ajoutant éventuellement quelques circuits annexes. La mémoire associative mémorise les tags, alors que la mémoire RAM mémorise les données de la ligne de cache, éventuellement avec quelques bits de contrôle. La ligne de cache est stockée à une adresse A dans la mémoire RAM et son tag est stocké à la même adresse, mais dans la mémoire CAM. Ce faisant, quand on envoie le tag à la mémoire CAM, elle renvoie l'adresse de la ligne de cache dans la mémoire RAM. Cette adresse est alors envoyée directement sur le bus d'adresse de la RAM, et la lecture est effectuée automatiquement. Il faut ajouter quelques circuits annexes pour garantir que les écritures se passent correctement dans les deux mémoires, mais rien de bien terrible. [[File:Cache fabriqué avec une mémoire associative et une RAM.png|centre|vignette|upright=3|Cache fabriqué avec une mémoire associative et une RAM]] Il est cependant possible d'optimiser un tel cache, en fusionnant la mémoire CAM et la mémoire RAM, afin d'éliminer des circuits redondants. Pour comprendre pourquoi, rappelons que les mémoires CAM sont composées d'un plan mémoire, d'un paquet de comparateurs et d'un encodeur. Quant à la mémoire RAM, elle est composée d'un décodeur connecté au plan mémoire. En mettant une CAM suivie d'une RAM, on a un encodeur dont l'entrée est envoyée à un décodeur. [[File:Cache totalement associatif naif.png|centre|vignette|upright=3|Cache totalement associatif naif]] Or, le décodeur réalise l'opération inverse de l'encodeur, ce qui fait que mettre les deux composants à la suite ne sert à rien. On peut donc retirer l'encodeur et le décodeur, et envoyer directement les résultats des comparateurs sur les entrées de commande du plan mémoire de la RAM. [[File:Cache totalement associatif optimisé.png|centre|vignette|upright=2|Cache totalement associatif optimisé]] Avec cette méthode, les circuits du cache ressemblent à ce qui illustré ci-dessous. Le tag est envoyé à chaque ligne de cache. Le tag envoyé est alors comparé avec le Tag contenu dans chaque ligne de cache, comme c'est le cas sur les mémoires associatives. Si une ligne de cache matche avec le tag envoyé en entrée, la ligne pour laquelle il y a eu une égalité est alors connectée sur les lignes de bit (''bitlines''). Cela est réalisé par un circuit commandé par le comparateur de la ligne de cache. Il ne reste plus qu'à sélectionner la portion de la ligne de cache qui nous intéresse, grâce à un paquet de multiplexeurs. Cela permet d'effectuer une lecture ou écriture, mais il faut aussi préciser si il y a eu un défaut de cache ou un succès. Un succès de cache a lieu quand au moins des comparaisons est positive, alors que c'est un défaut de cache sinon. En clair, détecter un succès de cache demande juste de connecter une porte OU à plusieurs entrées à tous les comparateurs. [[File:Organisation générale d'un cache totalement associatif.png|centre|vignette|upright=2|Organisation générale d'un cache totalement associatif.]] ===Les caches directement adressés=== Les caches directement adressés peuvent être vus comme un cache totalement associatif auquel on aurait ajouté des restrictions assez drastiques. Plus haut, on a vu qu'un cache totalement adressé est équivalent à la combinaison d'une CAM avec une RAM. La mémoire CAM prend en entrée un Tag et traduit celui-ci en une adresse qui commande la mémoire RAM interne au cache. Dans ce qui suit, l'adresse interne au cache sera appelé l''''indice''' pour éviter toute confusion. [[File:Cache hash table - 2.png|centre|vignette|upright=2|Fonctionnement interne du cache, expliquée sous forme abstraite, en utilisant la notion d'indice interne au cache.]] Les caches directement adressés cherchent à remplacer la mémoire CAM par un circuit combinatoire. Ce circuit traduit le Tag en indice, mais est beaucoup plus simple qu'une mémoire CAM. Mais qui dit circuit plus simple dit circuit plus limité. Un circuit combinatoire n'est pas aussi versatile que ce qui est permis avec une mémoire CAM. En conséquence, une restriction majeure apparait : toute adresse mémoire est associée dans une ligne de cache prédéfinie, toujours la même. L'association entre ligne de cache et adresse mémoire est faite par le circuit combinatoire, et ne peut pas changer. Les concepteurs de caches s'arrangent pour que des adresses consécutives en mémoire RAM occupent des lignes de cache consécutives, par souci de simplicité. Tout se passe comme suit la mémoire RAM était découpés en blocs de la même taille que le cache. La première adresse du bloc est associée à la première ligne de cache (celle d'indice 0), la seconde adresse est associée à la seconde adresse du_ bloc, et ainsi de suite. Le tout est illustré ci-dessous. [[File:Cache adressé directement.png|centre|vignette|upright=2|Cache adressé directement.]] Avec cette contrainte, le circuit de traduction de l'adresse en adresse mémoire pour la RAM interne au cache est drastiquement simplifié, et disparait même. Une partie de l'adresse mémoire sert à indiquer la position de la donnée dans le cache, le reste de l'adresse sert encode le tag et la position de la donnée dans le ligne de cache. [[File:Cache line.png|centre|vignette|upright=2|Adresse d'une ligne de cache sur un cache adressé directement.]] Un cache directement adressé est conçu avec une RAM, un comparateur, et un paquet de multiplexeurs. En général, la mémoire RAM stocke les lignes de caches complète. Il arrive que l'on utilise deux mémoires RAM : une pour les tags et une pour les données, mais cette technique augmente le nombre de circuits et de portes logiques nécessaires, ce qui réduit la capacité du cache. L'index à lire/écrire est envoyé sur l'entrée d'adresse de la RAM, la RAM réagit en mettant la ligne de cache sur sa sortie de donnée. Sur cette sortie, un comparateur compare le tag de la ligne de cache lue avec le tag de l'adresse à lire ou écrire. On saura alors si on doit faire face à un défaut de cache. Ensuite, un multiplexeur récupère la donnée à lire/écrire. [[File:Direct mapped cache - french.png|centre|vignette|upright=2|Cache directement adressé.]] L'accès à un cache directement adressé a l'avantage d'être très rapide vu qu'il suffit de vérifier une seule ligne de cache : celle prédéfinie. Mais ces caches ne sont cependant pas sans défauts. Vu que le cache est plus petit que la mémoire, certaines adresses mémoires se partagent la même ligne de cache. Si le processeur a besoin d’accéder fréquemment à ces adresses, chaque accès à une adresse supprimera l'autre du cache : tout accès à l'ancienne adresse se soldera par un défaut de cache. Ce genre de défauts de cache causés par le fait que deux adresses mémoires ne peuvent utiliser la même ligne de cache s'appelle un '''défaut par conflit''' (''conflict miss''). Les défauts par conflit n'existent pas sur les caches totalement associatifs. En conséquence, le taux de succès des caches directement adressés est assez faible comparé aux autres caches. [[File:Cache Block Basic Conflict.svg|centre|vignette|upright=1.5|Exemple de ''Conflict Miss''.]] ===Les caches associatifs par voie=== Les caches associatifs par voie sont un compromis entre les caches directement adressés et les caches totalement associatifs. Pour simplifier, ces caches sont composés de plusieurs caches directement adressés accessibles en parallèle, chaque cache/RAM étant appelé une '''voie'''. Avec ces caches, toute adresse mémoire en RAM est associée à une ligne de cache dans chaque voie. [[File:Cache associatif par voie.png|centre|vignette|upright=2|Cache associatif par voie.]] Le schéma ci-dessous compare un cache directement adressé et un cache associatif à deux voies. On voit que chaque adresse est associée à une ligne de cache bien précise avec un cache directement dressé, et à deux lignes de cache avec un cache associatif à deux voies. L'adresse sera associée à 4 lignes de cache sur un cache associatif à 4 voies, à 8 lignes pour un cache à 8 voies, etc. L'ensemble des lignes de cache associées à une adresse est appelé un '''ensemble'''. [[File:Cache Fill.svg|centre|vignette|upright=2|Comparaison entre un cache directement adressé et un cache associatif à deux voies.]] Sur ces caches, toute adresse est découpée en trois parties : un tag, un index, et un décalage, comme sur les caches directement adressés. Comme vous pouvez le voir, l'organisation est identique à celle d'un cache totalement associatif, à part que chaque ensemble tag-ligne de cache est remplacé par une mémoire RAM qui en contient plusieurs. [[File:Implémentation d'un cache associatif par voie.png|centre|vignette|upright=2|Implémentation d'un cache associatif par voie.]] Le risque de conflits d'accès au cache est donc réduit sur un cache associatif à plusieurs voies, et il est d'autant plus réduit que le cache a de voies. Par contre, leur conception interne fait qu'ils ont un temps d'accès légèrement élevé que les caches directement adressés. Les caches associatifs par voie ont donc un taux de succès et un temps d'accès intermédiaire, situé entre les caches directement adressés et totalement associatifs. Ils sont une sorte de compromis entre réduction des défaut par conflits d'accès au cache et temps d'accès, et complexité des circuits. ==Les optimisations des caches associatifs par voie== Les caches partiellement associatifs regroupent les caches associatifs par voie et directement adressés, ainsi que leurs variantes. En clair : tous les caches qui ne sont pas totalement associatifs. Ils peuvent être optimisés de nombreuses manières, que ce soit pour gagner en performance ou pour économiser de l’énergie. Dans cette section, nous allons voir quelles sont ces optimisations. ===Les caches pseudo-associatifs=== Les caches adressés par voie contiennent une mémoire SRAM par voie. En théorie, les voies sont accédées en parallèles, en même temps, afin de voir si l'on a un succès de cache ou un défaut. Les '''caches pseudo-associatifs''' sont identiques aux caches associatifs par voie, si ce n'est qu'ils vérifient chaque voie une par une. Ils ont été utilisés sur des processeurs commerciaux, un exemple étant l'IBM 370. Là encore, on perd en performance pour gagner en consommation d'énergie. Le temps d'accès dans le meilleur des cas est plus faible pour les caches pseudo-associatifs, mais le pire des cas teste tous les caches avant de tomber sur le bon. Les performances sont donc réduites. Mais la consommation énergétique est meilleure, vu qu'on ne vérifie pas forcément toutes les voies en parallèle. On teste la première voie, éventuellement la seconde, peut-être la troisième, etc. Mais dans le cas général, on ne teste qu'une partie des voies, pas toutes, ce qui donne un gain en termes d'énergie. L'implémentation de caches de ce genre demande que l'on parcoure les voies une par une, en commençant de la première jusqu'à la dernière. Pour cela, un simple compteur suffit. Suivant la valeur du compteur, la voie associée est activée puis accédée. Toute la complexité revient à ajouter un circuit qui prend la valeur du compteur, et active la voie associée, lance un accès mémoire dessus. Vu que les voies sont chacune des caches ''direct mapped'', il suffit pour cela de geler les entrées d'adresse, soit en les déconnectant, soit en utilisant du ''clock gating'' ou de l'évaluation gardée. Les détails d'implémentation, non-cités ici, varient selon le cache. ===La prédiction de voie=== Pour réduire le temps d'accès des caches pseudo-associatifs, certains chercheurs ont inventé la '''prédiction de voie''', qui consiste à faire des paris sur la prochaine voie accédée. L'idée est d'accéder à la voie qui contient la donnée voulue du premier coup, en lisant celle-ci en priorité. Dans son implémentation la plus simple, le cache reste un cache pseudo-associatif. Lors d'un accès au cache, les voies sont toutes parcoures une par une. Par contre, les voies ne sont donc pas parcourues de la première vers la dernière, mais dans un ordre différent. Cette technique permet de mettre en veille les voies sur lesquels le processeur n'a pas parié, ce qui permet de diminuer la consommation énergétique du processeur. C'est plus efficace que d'aller lire plusieurs données dans des voies différentes et de n'en garder qu'une. L'implémentation est assez simple : il suffit d'ajouter un circuit de prédiction de voie,relié au compteur de voie. Une amélioration de la technique fait fonctionner le cache comme un intermédiaire entre cache pseudo-associatif et associatif par voies. L'idée est de chercher la voie prédite en premier, puis de chercher dans toutes les voies en parallèle en cas de défaut de cache. Au lieu d'attendre que les comparaisons de tags donnent leur résultat, le processeur sélectionne automatiquement une voie et configure les multiplexeurs à l'avance. Si le processeur ne se trompe pas, le processeur accède à la donnée plus tôt que prévu. S'il se trompe, le processeur annule la lecture effectuée en avance et recommence en faisant un accès en parallèle aux autres voies. Le compromis entre performance et consommation d'énergie est alors différent. On économise de l'énergie par rapport à un cache associatif par voie, au prix d'une petite perte de performance (doublement des temps d'accès). Mais par rapport à un cache pseudo-associatif, l'économie d'énergie est bien moindre, au prix d'un gain en performance assez manifeste. Prédire quelle voie sera la bonne est assez simple. En vertu du principe de localité, les accès futurs ont des chances de tomber dans les voies les plus fréquemment utilisées ou dans celle plus récemment utilisée. Il suffit de retenir la voie la plus récemment accédée dans un registre, qui sera utilisée comme prédiction. Pour vérifier que la prédiction est correcte, il suffit de comparer le registre et le résultat obtenu après vérification des tags. Cependant, on peut complexifier l'implémentation pour prendre en compte l'adresse à lire/écrire, l'instruction à l'origine de l'accès mémoire ou tout autre paramètre utile. Par exemple, des instructions différentes ont tendance à aller chercher leurs données dans des ensembles différents et la voie à choisir n'est pas la même. Pour cela, il suffit d'utiliser un cache pour stocker la correspondance instruction - voie. Pour plus de simplicité, la mémoire cache des prédictions est parfois remplacée par une RAM, qui est adressée : * soit par le program counter de l'instruction à l'origine de l'accès (en réalité, seulement quelques bits de poids faible de l'adresse) ; * soit par l'adresse à accéder (là encore, quelques bits de poids faible) ; * soit (pour les modes d'adressage qui utilisent un registre de base et un décalage) par un XOR entre les bits de poids faible de l'adresse de base et le décalage ; * soit par autre chose. ===La mise en veille sélective des voies=== Les caches associatifs ont tendance à utiliser beaucoup d'énergie, même quand on n'y accède pas. Aussi, certains processeurs détectent quand le cache est peu utilisé et en profitent pour mettre en veille les voies inutilisées. Vous vous demandez certainement ce qui se passe quand une donnée à lire/écrire est dans une voie désactivée. La réponse est que le cache détecte cette situation, car elle déclenche un succès de cache. Les ''tags'' ne sont en effet pas désactivés, seules les données sont mises en veille. L'implémentation est plus simple sur les caches qui séparent les tags et les données dans deux RAM différentes. Cette optimisation marche surtout sur les gros caches, qui ont des chances d'avoir une portion significative d’inutilisée (pas assez de données pour les remplir), donc généralement les caches L3/L4. Par exemple, les processeurs d'Intel de microarchitecture Ivy Bridge disposent d'un cache de 8 mébioctets à 16 voies, qu'ils peuvent faire passer à 512 kibioctets si le besoin s'en fait sentir. Quand ces processeurs détectent une faible activité, ils mettent en veille 14 voies et n'en gardent que 2 d'actives. Évidemment, les 14 voies sont vidées avant d'être mises en veille, afin qu'une aucune donnée ne soit perdue. ===Les caches ''skew-associative''=== Vous aurez remarqué que dans une voie, les lignes sont accédées en adressage direct : les défauts par conflit sont possibles sur un cache associatif par voie. Pour éviter cela, certains chercheurs ont créé des '''caches ''skew associative''''' (ou associatifs à biais). Pour faire simple, les index des lignes de cache subissent un petit traitement avant d'être utilisés. Le traitement en question est différent suivant la voie de destination, histoire que deux adresses mémoires avec des index identiques donnent des index différents après traitement. Le traitement en question est souvent une permutation des bits de l'index, qui est différente suivant la voie prise, ou un simple XOR avec un nombre qui dépend de la voie. [[File:Implémentation d'un cache skew associative.jpg|centre|vignette|upright=2|Implémentation d'un cache skew associative.]] ==Les caches splittés (''phased caches'')== Dans cette section, nous allons voir les '''caches splittés''' (''phased caches''), qui sont une variante des caches ''direct-mapped'', dans lequel le cache est accédé en deux étapes consécutives. Il ne s'agit pas des caches pipelinés, que nous verrons dans le chapitre sur les processeurs pipélinés, mais laissons cela à plus tard. Il est possible d'appliquer la même méthode sur un cache associatif par voie, mais il y a des méthodes plus simples, qui permettent là aussi d’accéder au cache en plusieurs étapes consécutives. L'idée est de scinder le cache en deux : une mémoire pour les tags, une autre pour les données de la ligne de cache. Les bits de contrôle peuvent être mis dans l'une ou l'autre SRAM, mais ils sont souvent mis dans la RAM pour les tags. En faisant cela, quelques optimisations deviennent possibles, afin de réduire la consommation énergétique en contrepartie d'une perte de performance. La technique s'implémente différemment pour les caches totalement associatifs et partiellement associatifs. Les caches totalement associatifs splittés sont ceux formés en combinant un cache associatif avec une CAM et une RAM combinée. On envoie l'adresse à lire/écrire à la mémoire associative, elle répond en envoyant une adresse à la mémoire RAM. L'accès se fait donc en deux temps, avec l'adresse dans la RAM comme intermédiaire. Il est possible de séparer physiquement les deux étapes en insérant un registre entre la CAM et la RAM, ce qui permet aussi de pipeliner l'accès. Mais c'est rarement fait en pratique, car le cout en circuit d'une mémoire CAM est trop important. L'équivalent pour un cache totalement associatif optimisé, sans CAM et RAM séparée, est trop gourmande en interconnexions pour être implémentée. Les caches totalement associatifs splittés sont donc très rares, l'auteur ne connait aucun exemple de processeur avec un tel cache. Il existe une technique équivalente pour les caches ''direct-mapped'', mais elle demande une certaine modification du cache. Dans les caches ''direct-mapped'' non-splittés, on trouve une mémoire SRAM dont chaque mot mémoire contient une ligne de cache entière, tag inclus. Dans leurs versions splittés, la SRAM est séparée en deux : une pour les tags, une autre pour les données. Précisons qu'il s'agit bien de deux mémoires SRAM adressables. L'adresse à laquelle accéder est envoyée à la SRAM des tags, puis ensuite à la SRAM des données si besoin. L'idée est d’accéder aux tags pour déterminer s'il y a un succès de cache ou un défaut, et ensuite d'accéder aux données. On n’accède pas aux données en parallèle des tags. Faire cela est évidemment plus lent. En cas de défaut de cache, le temps d'accès est similaire : le tag ne correspond pas, on n'accède pas à la SRAM pour les données. Par contre, vu qu'on n'a pas activé la SRAM pour les données, on économise un peu d'énergie, ce qui réduit la consommation d'énergie. En cas de succès de cache, on accède à la SRAM pour les tags, puis à celle pour les données. Pas d'économie d'énergie à l'horizon, sans compter que le temps d'accès augmente : on accède au cache en deux étapes au lieu de faire les deux accès en parallèle. [[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]] Précisons cependant que ce design peut avoir deux avantages en termes de performance. Premièrement, le temps d'accès au cache est légèrement amélioré en cas de défaut de cache. En effet, la SRAM des tags est assez petite, idem pour celle des données. Leur temps d'accès est donc plus faible que pour une grosse SRAM contenant données et tags. Le gain en temps d'accès est donc un avantage, qui ne se manifeste surtout en cas de défaut de cache. Un autre avantage est que l'accès au cache se pipeline plus facilement, ce qui fait qu'on peut effectuer plusieurs accès simultanés au cache. Mais nous verrons cela dans quelques chapitres. ===L'exemple des processeurs 386 et du contrôleur de cache 82385=== Il est important de noter que la séparation entre tags et RAM peut être telle que les deux ne sont pas sur la même puce de silicium ! Voire que les deux sont séparés du processeur ! C'était le cas quand les mémoires caches ont été introduites sur les processeurs grand public, notamment sur les premiers processeurs Intel. La miniaturisation n'avait pas avancé au point où placer un cache dans le processeur était possible. Sur le processeur 386 d'Intel, le cache était un cache splitté, séparé du processeur. Concrètement, le processeur i386 était couplé à un contrôleur de cache Intel 82385 contenant les ''tags'' et les bits de contrôle, et une mémoire SRAM pour les lignes de cache. Le 82385 était un composant passif, qui n'était pas un intermédiaire entre processeur et mémoire RAM. Il surveillait ce qui se passait sur le bus de données et répondait à la place de la RAM pour certaines lectures. La conséquence est que c'était un cache ''write through'' : le processeur envoyait les écritures sur le bus mémoire, le cache les voyait passer et agissait en conséquence en cas de succès de cache. Il pouvait commander soit un cache ''direct mapped'', soit associatif à deux voies. La différence entre les deux est que le 82385 était relié à une mémoire SRAM avec un cache ''direct mapped'', deux SRAM pour deux voies. Pour avoir un cache associatif à deux voies, le 82385 devrait gérer deux signaux ''chip select'' pour activer chaque SRAM/voie suivant les besoins. Il avait précisément quatre signaux CS : deux par SRAM, un pour les lectures, un pour les écritures. Notons que les SRAM utilisées devaient être des SRAM avec un bus de données de 32 bits, des SRAM 16 ou 8 bits ne fonctionnaient pas. Le 82385 rajoutait 4 sorties, pour masquer chaque octet dans ces 32 bits. Le choix entre les deux était le fait d'une entrée : la mettre à 0 indiquait un cache ''direct mapped'', la mettre à 1 forçait un cache à deux voies. Il gérait aussi les accès mémoire non-cacheable, à savoir des accès mémoire qui ne doivent pas être pris en compte par le cache. Pour cela, il avait une entrée NCA (''Non Cacheable Access'') : le cache était "désactivé" quand cette entrée était à 1. C'est un peu une sorte de ''chip select'' pour le 82385, limitée aux accès mémoire. Cette entrée permettait de programmer des intervalles d'adresse auxquels ne pas répondre, en utilisant des circuits de décodage d'adresse adaptés. Il avait aussi une entrée X16, qui permettait d'identifier les accès soit à un composant 16 bits, soit à une entrée-sortie. De tels accès ne doivent pas être mis en cache, ce qui était garanti par cette entrée. Pour rappel, le processeur utilisait un espace d'adressage séparé pour les entrées-sorties, et utilisait donc un bit IO. Ce bit IO pouvait être utilisé pour déterminer le bit X16, qui inhibe l'action du cache. Cette surveillance du bus permettait de gérer une forme limitée de cohérence des caches par invalidation. Dès que le 82385 détectait une prise de contrôle du bus par autre chose que le processeur, il surveillait les adresses transmises sur le bus. En cas de succès de cache, la ligne de cache associée était invalidée. Au-delà de ca, le 82385 avait une entrée FLUSH, qui ordonnait une invalidation totale du cache. Si cette entrée est mise à 1, toutes les lignes de cache sont invalidées. Les ''tags'' sont marqués comme invalides, mais les lignes de cache elles-mêmes ne sont pas touchées. Le 82385 pouvait être intégré dans un système à deux processeurs, voire plus. Pour cela, chaque processeur avait son propre 82385 et sa SRAM rien qu'à lui. Il n'y avait pas de cache partagé entre les deux processeurs. Par contre, les deux caches étaient reliés au même bus système. Pour qu'ils ne se marchent pas sur les pieds, il y avait des circuits d'arbitrage pour gérer l'accès au bus. Un des deux 82385 était mis en mode maitre, l'autre était en mode esclave. Le 82385 maitre pouvait prendre le contrôle du bus, le 82385 esclave devait demander l'autorisation au premier pour accéder au bus système. ===L'exemple des processeurs Intel de microarchitecture ''Broadwell''=== Un autre exemple est celui du cache L4 des processeurs Broadwell et de quelques processeurs séparés. Ces processeurs ont une organisation en ''chiplet'' où le processeur incorpore plusieurs puces séparées : une puce pour le processeur proprement dit, une puce nommée ''Crystal Well'' pour le cache L4, et une puce IO pour la communication avec la RAM et la carte mère. Le processeur incorporait un cache L4 de 128 mébioctets, composé de mémoire eDRAM, qui était dispersé entre ''Crystal Well'' et les autres puces. Les données du cache L4 étaient dans ''Crystal Well'', alors que les Tags étaient soit dans le processeur lui-même, soit dans la puce IO ! La puce ''Crystal Well'' était une mémoire DRAM adressable tout ce qu'il y a de plus basique, avec cependant quelques optimisations notables. Par exemple, elle avait deux bus séparés pour l'écriture et la lecture. De plus, elle avait une organisation interne avec 128 banques, contre moins d'une dizaine pour la DDR de l'époque et environ 32 banques pour la DDR5 moderne. Elle contenait aussi quelques circuits pour gérer son rôle de mémoire cache, mais rien en ce qui concerne la gestion des tags eux-mêmes. Sur les processeurs de microarchitecture ''Broadwell'', les tags étaient placés dans le CPU et précisément dans le cache L3. À chaque accès mémoire au cache L3, les tags du cache L4 étaient consultés en parallèle. De fait, l'accès au cache L4 était assez rapide, malgré le fait que les données étaient dans une puce à part. Ajoutons à cela que le processeur et ''Crystal Well'' n'avaient pas la même finesse de gravure ni la même technologie de fabrication. Les tags étaient implémentés avec de la SRAM contre la DRAM pour les données, ce qui fait que la consultation des tags était plus rapide que l'accès aux données. Par la suite, dans certains CPU de microarchitecture ''skylake'', les tags ont été déplacés en-dehors du processeur pour finir dans le contrôleur mémoire. En faisant cela, le cache L4 pouvait être utilisé par autre chose que le processeur, et notamment par la carte graphique intégrée au CPU. Avec ''broadwell'', le fait que les tags étaient consultés en cas d'accès au L3 empêchait au GPU intégré de consulter le cache L4. Mais en déplaçant les tags dans le contrôleur mémoire, ce n'est plus le cas vu que la carte graphique a aussi accès au bus mémoire. Par contre, le temps d'accès augmente comparé à la solution précédente. On n'accède pas aux tags du L4 en parallèle du L3 : à la place, il faut consulter les tags du L3, détecter un défaut de cache L3, et ensuite accèder aux tags. ===Les caches RAM-configurables=== Un autre avantage des caches splittés est qu'on peut les modifier pour servir à la fois de mémoire cache, mais aussi de ''local store'', de mémoire RAM de petite taille. Le fonctionnement est assez simple à comprendre. Lors d'un accès au cache, on accède aux tags, puis à la RAM interne au cache. Lors d'un accès au ''local store'', on contourne l'accès au tags et on accède à la RAM interne au cache directement. Il s'agit de la technique du '''cache RAM-configurable''. L'usage de cache RAM-configurable est fréquent sur les cartes graphiques récentes, qui incorporent un ou plusieurs processeurs multicoeurs, dont le cache L1 de données est un cache RAM-configurable. [[File:Hydride cache - local store.png|centre|vignette|upright=2.0|Hydride cache - local store]] ===La compression de cache=== Une autre optimisation permise par les ''phased caches'' est l'implémentation de techniques de '''compression de cache''', qui visent à compresser des lignes de cache. L'intérêt est qu'on peut stocker plus de données dans le cache, à capacité égale. L'inconvénient est qu'on doit compresser/décompresser les lignes de cache, ce qui demande un circuit en plus et allonge les temps d'accès. En effet, le temps mis pour compresser/décompresser une ligne de cache s'ajoute au temps d'accès. Aussi, la compression de cache sert surtout pour les caches de bas niveau dans la hiérarchie mémoire, les gros caches aux temps d'accès assez longs. Une première technique, assez simple à implémenter et peu couteuse en circuit, est celle de la '''compression des lignes de cache nulles'''. Elle compresse uniquement les lignes de cache qui ne contiennent que des zéros. L'idée est qu'on ajoute, dans la mémoire des tags, un bit de contrôle pour chaque ligne de cache appelé le bit ''null''. Il indique si la ligne de cache ne contient que des zéros. Quand on lit une ligne de cache, la mémoire des tags est accédée et on vérifie le bit ''null'' : s'il vaut 1, on n'accède pas à la mémoire cache de données et un multiplexeur envoie un zéro sur le port de lecture. Le bit ''null'' est fixé lors de l'écriture d'une ligne de cache : elle passe dans un comparateur avec zéro relié à la mémoire des tags. La comparaison avec zéro peut se faire en parallèle de l'écriture ou avant (dans ce cas, on n'écrit pas la ligne de cache dans le cache). Les autres techniques de compression de cache permettent de compresser autre chose que des lignes de cache nulles. L'idée est qu'une ligne de cache physique peut par moment mémoriser plusieurs lignes de caches compressées. Par exemple, prenons un cache dont les lignes de cache font 64 octets. Il est possible de compresser deux lignes de cache pour qu'elles fassent chacune 32 octets, et les stocker dans une seule ligne de cache. Les deux lignes de cache auront des tags différents, mais pointeront sur la même ligne de cache physique. Et cela demande d'utiliser un ''phased cache'' dont la mémoire pour les tags est plus grande que la mémoire pour les données. Il n'y a donc plus une bijection entre tags et ligne de cache, mais une relation surjective. Chose qui n'est possible qu'avec un ''phased cache''. De plus, des bits de contrôles associés à chaque ''tag'' indiquent où se trouvent les lignes de cache compressées dans la ligne de cache : est-ce que c'est les 32 octets de poids fort ou de poids faible ? [[File:Compression de cache.png|centre|vignette|upright=2|Compression de cache]] Il ne semble pas que les techniques de compression de cache soient implémentées sur les processeurs modernes. Aucun n'utilise de compression de cache, à ma connaissance. Il faut dire que les techniques connues sont de mauvais compromis : le temps d'accès du cache augmente beaucoup, le cout en circuit pourrait être utilisé pour un cache non-compressé mais plus grand. Et notons que la compression de cache ne marche que si les données peuvent se compresser. Si ce n'est pas le cas, une partie de la mémoire des tags est inutilisée. Une revue de la littérature académique sur la compression de cache est disponible via ce lien, pour les curieux : * [https://inria.hal.science/hal-03285041 Understanding Cache Compression, par Carvalho et Seznec]. ==L'adressage physique ou logique des caches== Le cache utilise les adresses à lire/écrire pour déterminer s'il a une copie de la donnée en son sein. Mais l’interaction entre caches et mémoire virtuelle donne lieu à un petit problème : l'adresse utilisée est-elle une adresse virtuelle/logique ou physique ? La réponse varie suivant le processeur : certains caches utilisent l'adresse virtuelle, tandis que d'autres prennent l'adresse physique. On parle de cache '''virtuellement tagué''' dans le premier cas et de cache '''physiquement tagué''' dans le second. {| |[[File:Cache tagué virtuellement.png|vignette|Cache tagué virtuellement.]] |[[File:Cache tagué physiquement.png|vignette|Cache tagué physiquement.]] |} ===L'accès à un cache physiquement/virtuellement tagué=== La manière d'accéder à un cache dépend de s'il est virtuellement ou physiquement tagué. Il faut utiliser l'adresse virtuelle pour les premiers, physique pour les seconds. Avec un cache virtuellement tagué, l'adresse logique peut être envoyée directement au cache. La MMU ne traduit les adresses que s'il faut accéder à la mémoire RAM. Ces caches sont donc plus rapides. Avec un cache physiquement tagué, le processeur doit traduire l'adresse logique en adresse physique dans la MMU, avant d'accéder au cache. La traduction d'adresse se fait soit en accédant à une table des pages en mémoire RAM, soit en accédant à un cache spécifiquement dédié à accélérer la traduction d'adresse, la TLB (''Translation Lookaside Buffer''). Dans la quasi-totalité des cas, la traduction d'adresse passe par la TLB, ce qui fait qu'elle est raisonnablement rapide. Toujours est-il que chaque accès au cache demande d'accéder à la TLB et de faire la traduction d'adresse avant d'accéder au cache. L'accès est donc plus lent que sur les caches virtuellement tagués, où les accès sont plus directs. [[File:Virtual and Physical addressing.svg|centre|vignette|upright=2|Cache tagué virtuellement versus physiquement tagué.]] ===Les défauts des caches virtuellement tagués=== Les caches physiquement tagués sont moins rapides que les caches virtuellement adressés. Pourtant, les caches virtuellement tagués sont peu fréquents sur les processeurs modernes. Et la raison est assez intéressante : c'est une question d'adresses homonymes et synonymes. ====Les droits d'accès doivent être vérifiés lors d'un accès au cache==== Un premier problème est que la protection mémoire est compliquée avec de tels caches. Rappelons que certaines portions de mémoire sont accessibles seulement en lecture, ou sont interdites en écriture, sont inexécutables, etc. Ces droits d'accès sont gérés par la MMU, qui vérifie pour chaque accès mémoire que l'accès est autorisé. En bypassant la MMU, l'accès au cache virtuellement tagué ne permet pas de faire ces vérifications. Il est possible de charger une donnée en lecture seule dans le cache, mais d'y faire des accès en écriture pour les accès ultérieurs. Les solutions à cela sont multiples. La première consiste à consulter la MMU en parallèle de l'accès au cache. L'accès au cache est alors réalisé de manière spéculative, et est ensuite confirmé/annulé une fois que la MMU a rendu son verdict. Les performances du cache restent alors les mêmes : l'accès à la MMU se fait en parallèle de l'accès au cache, pas avant. Une autre solution est d'ajouter les droits d'accès en question dans la ligne de cache, dans les bits de contrôle situés après le Tag. Chaque accès au cache récupère ces bits de contrôle et vérifie si l'accès est autorisé. L'inconvénient est que les lignes de cache deviennent plus longues, les droits d'accès sont dupliqués entre MMU et cache. Mais si le budget en transistor suit, ce n'est rien d'insurmontable. ====Les adresses homonymes perturbent la gestion du cache==== Pour rappel, une adresse logique homonyme correspond à plusieurs adresses physiques différentes. Elles surviennent quand chaque programme a son propre espace d'adressage. Dans ce cas, une adresse logique correspondra à une adresse physique différente par programme.Une autre manière de voir les choses est qu'il y a en réalité deux adresses homonymes, qui ont la même valeur, mais appartiennent à des espaces d'adressage différentes. Et c'est cette seconde interprétation que nous allons utiliser. Les caches doivent gérer ces adresses homonymes et faire en sorte que la lecture/écriture d'une adresse homonyme se fasse à la bonne adresse physique, dans la bonne ligne de cache. Et autant un cache physiquement tagué n'a aucun problème avec ça, vu qu'il ne gère que des adresses physiques, autant des problèmes surviennent avec les caches virtuellement tagués. Le problème est que les caches virtuellement tagués doivent faire la différence entre deux adresses homonymes de même valeur. Pour corriger ces problèmes, il existe deux grandes méthodes. La première méthode est simple : '''vider les caches''' en changeant de programme. Leur contenu est rapatrié en mémoire RAM, puis les caches sont remis à zéro. Le vidage du cache recopie les lignes de cache ''dirty'' (modifiées) en RAM, puis efface/invalide tout le cache. C'est à cela que servent les instructions ''clean'' et d'invalidation vues plus haut, elles ont été inventées pour cette situation précise. Lorsque le système d'exploitation déclenche une commutation de contexte, à savoir qu'il change le programme en cours d'exécution, le processeur vide tous les caches du processeur. Les interruptions font la même chose, elles vide tous les caches du processeur. Une seconde méthode numérote chaque programme en cours d'exécution, chaque processus. Le numéro attribué est spécifique à chaque processus, ce qui fait qu'il est appelé un '''identifiant de processus CPU'''. Le processeur mémorise l'identifiant du programme en cours d'exécution dans un registre dédié. L'identifiant de processus CPU est utilisé lors des accès mémoire. Chaque ligne de cache contient le numéro de l'espace d'adressage associé, dans son ''tag''. Lors de chaque accès mémoire, l'ID du registre est comparé à l'ID de la ligne de cache accédée, pour vérifier que l'accès mémoire accède à la bonne donnée. Cette méthode n'est pas très économe en termes de transistors. L'usage d'identifiant de processus CPU est clairement meilleure en termes de performance, les commutations de contexte sont plus rapides. Par contre, le budget en transistor est plus important. Un autre défaut de cette méthode est que l'identifiant de processus est généralement codé sur une dizaine de bits, alors que le système d'exploitation utilise des identifiants de processus beaucoup plus larges, de 32 à 64 bits sur les CPU 32/64 bits. L'OS doit gérer la correspondance entre identifiants de processus CPU et ceux de l'OS. Parfois, pour cette raison, les OS n'utilisent pas toujours ce système d'identifiant de processus CPU. ====Les adresses synonymes perturbent aussi la gestion du cache==== La gestion des adresses synonymes est aussi un gros problème sur les caches virtuellement tagués. Pour rappel, il s'agit du cas où des adresses logiques différentes pointent vers la même adresse physique. Typiquement, quand deux programmes se partagent un morceau de mémoire, ce morceau correspondra à des adresses synonymes dans les deux espaces d'adressage. Mais il arrive que l'on ait des adresses synonymes dans le même espace d'adressage, ce n'est pas si rare ! Autant les adresses synonymes ne posent aucun problème avec les caches physiquement tagués, ce n'est pas le cas avec les caches virtuellement adressés. Sur ces caches, deux adresses logiques synonymes vont tomber dans deux lignes de cache différentes. Corriger ce problème demande d'ajouter des circuits annexes pour détecter les adresses synonymes, qui sont vraiment complexes et ont un cout en termes de performance. Aussi, les caches virtuellement tagués sont très peu utilisés sur les processeurs modernes. ===Les caches virtuellement adressés, mais physiquement tagués=== Si les caches physiquement et virtuellement tagués ont des défauts, il existe un intermédiaire qui est un bon compromis entre ces deux extrêmes. Il s'agit des '''caches virtuellement adressés - physiquement tagués''', aussi appelés '''caches pseudo-virtuels'''. Pour comprendre comment ils fonctionnent, précisons que ces caches sont soit des caches ''direct-mapped'', soit des caches associatifs par voie (composés de plusieurs RAM ''direct-mapped'' accédées en parallèle, plusieurs voies). L'accès à ce genre de cache se fait en deux temps : on accède à un ou plusieurs RAM ''direct-mapped'' et on vérifie ensuite les ''Tags'' pour sélectionner la bonne voie. Sur les caches ''direct-mapped'', on n'a qu'une seule RAM ''direct-mapped''. Sur les caches associatifs, on a plusieurs RAM ''direct-mapped'', appelées des voies, qui sont accédées en parallèle. L'accès se fait donc en deux étapes : adresser les RAM ''direct-mapped'' avec un indice, vérifier les ''tags'' avec le reste de l'adresse. Une autre chose à rappeler est que l'adresse logique est composée de deux parties : un numéro de page logique qui indique dans quel page se situe l'adresse, un décalage/''offset'' qui indique la position de l'adresse dans la page. La traduction d'adresse transforme le numéro de page logique en numéro de page physique, mais laisse le décalage intouché. L'idée est d'utiliser le décalage pour adresser les RAM avec le décalage, tandis que le numéro de page sert de ''tag''. Le décalage est découpé en deux lors de l'accès au cache : les bits de poids fort forment l'indice (l'adresse envoyée à la voie), les bits de poids faible donnent la position de l'adresse dans la ligne de cache. L'idée est d'utiliser un numéro de page physique pour les ''tags'', mais d'adresser les voies avec le décalage logique. Les deux servent à des instants différents : vérification des ''tags'' pour l'adresse physique, accès aux voies pour l'adresse logique. Ainsi, le problème des adresses synonymes ou homonymes est résolu par l'utilisation de l'adresse physique pour les tags. Par contre, l'accès au cache est plus rapide, car on utilise l'adresse logique pour la première étape. Le processeur accède à la TLB et récupère l'adresse physique pendant que l'on adresse les voies, les deux sont faits en parallèle, ce qui fait que tout se passe comme si l'accès à la TLB était gratuit. La TLB étant assez rapide comparé au cache, l'adresse physique est disponible quand on doit faire la comparaison avec les ''tags''. [[File:Virtual - Physical - Pseudo Virtual addressing.svg|centre|vignette|upright=2|Adressage pseudo virtuel des caches.]] Il s'agit d'un excellent compromis entre performance et correction des problèmes des adresses synonymes/homonymes. Tous les caches des processeurs haute performance utilisent cette méthode, au moins pour leurs caches L1. Les caches L2 tendent à utiliser des caches physiquement adressés, pour lesquels la latence d'accès est suffisante pour qu'on accède à la TLB en amont. La raison est assez simple à expliquer, elle provient d'une contrainte assez précise sur le calcul de l'indice. La conséquence est qu'un cache ''direct-mapped'' ne peut pas dépasser la taille d'une page, soit 4 kibioctets sur les ordinateurs actuels. Sur les caches associatifs, on peut dépasser cette limite en augmentant le nombre de voies, mais la taille maximale d'une voie reste celle d'une page. Cette contrainte n'est pas trop grave sur les caches de petite taille, dont les caches L1. La plupart d'entre eux ont trouvé un compromis idéal avec moins d'une dizaine de voies par cache, chacun de 4 kibioctets, ce qui donne des caches allant de 16 à 64 kibioctets, soit entre 4 et 16 voies. Par contre, un cache de grande taille doit utiliser un grand nombre de voies, ce qui est peu pratique. Aussi, cette technique de caches pseudo-virtuels n'est pas toujours appliquée sur les caches L2, qui sont physiquement adressés. Il faut dire qu'on accède au cache L2 lors d'un défaut dans le cache L1, et l'adresse physique est disponible à ce moment-là, elle a déjà été récupérée lors de l'accès au cache L1. On peut donc l'utiliser pour adresser le cache L2 sans perte de performance. ==Le remplacement des lignes de cache== Lorsqu'un cache est rempli et qu'on charge une nouvelle donnée dedans, il faut faire de la place pour cette dernière. Dans le cas d'un cache directement adressé, il n'y a rien à faire vu que la ligne de cache à évincer est déterminée lors de la conception du cache. Mais pour les autres caches, la donnée peut aller dans n'importe quelle ligne ou voie. Or, le choix des données à rapatrier en RAM doit être le plus judicieux possible : on doit virer de préférence des données inutiles. Rapatrier une donnée qui sera surement utilisée sous peu est inutile, et il vaudrait mieux supprimer des données qui ne serviront plus ou alors dans longtemps. Il existe différents algorithmes spécialement dédiés à résoudre ce problème efficacement, directement câblés dans les unités de gestion du cache. Certains sont vraiment très complexes, aussi je vais vous présenter quelques algorithmes particulièrement simples. Mais avant de voir ces algorithmes, il faut absolument que je vous parle d'une chose très importante. Quel que soit l'algorithme en question, il choisit la ligne de cache à évincer et recopie son contenu dans la RAM. Ce qui demande d'identifier et de sélectionner une ligne de cache parmi toutes les autres. Pour cela, le circuit de remplacement attribue une adresse chaque ligne de cache ! Vous avez bien vu : chaque ligne de cache est numérotée par une adresse, interne au cache. ===Le remplacement aléatoire=== Premier algorithme : la donnée effacée du cache est choisie au hasard ! C'est contre-intuitif, mais cet algorithme donne des résultats assez honorables, en plus d'utiliser très peu de portes logiques (un générateur de nombres pseudo-aléatoire est un circuit assez simple). Généralement, les défauts de cache sont séparés par un nombre assez important et irrégulier de cycles d'horloge. Dans ces conditions, cette technique donne un bon résultat. ===FIFO : first in, first out=== Avec l'algorithme FIFO, la donnée effacée du cache est la plus ancienne, celle chargée dans le cache avant les autres. Cet algorithme est très simple à implémenter en circuit, concevoir une mémoire de type FIFO n'étant pas très compliqué, comme on l’a vu dans le chapitre dédié à ce type de mémoires. Et on peut dire que dans le cas d'un cache, l'implémentation est encore plus simple et se contente d'un seul registre/compteur. Typiquement, il suffit d'ajouter un registre qui mémorise où se situe la donnée la plus récente. Toute insertion d'une nouvelle donnée se fait à l'adresse suivante, ce qui demande juste d'incrémenter le registre avant d'utiliser son contenu pour l'accès mémoire. [[File:Algorithme FIFO de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme FIFO de remplacement des lignes de cache.]] Cet algorithme possède une petite particularité sur les caches associatifs par voie : en augmentant le nombre d'ensembles, les performances peuvent se dégrader : c'est ce qu'on appelle l''''anomalie de Bélády'''. ===MRU : most recently used=== Avec l'algorithme MRU, la donnée remplacée est celle qui a été utilisée le plus récemment. Cet algorithme s'implémente simplement avec un registre, dans lequel on place le numéro de la dernière ligne de cache utilisée. Cet algorithme de remplacement est très utile quand un programme traverse des tableaux du premier élément jusqu'au dernier : les données du tableau sont rarement réutilisées, rendant le cache inutile. Il est prouvé que dans ces conditions, l'algorithme MRU est optimal. Mais dans toutes les autres conditions, cet algorithme a des performances assez misérables. ===LFU : least frequently used=== Avec l'algorithme LFU, la donnée supprimée est celle qui est utilisée le moins fréquemment. Cet algorithme s'implémente en associant un compteur à chaque ligne de cache, qui est incrémenté à chaque accès mémoire. La ligne la moins récemment utilisée est celle dont le compteur associé a la plus petite valeur. Implémenter cet algorithme prend pas mal de transistors, car il faut rajouter autant de compteurs qu'il y a de lignes de cache, en plus d'un circuit pour comparer les compteurs et d'un encodeur. [[File:Algorithme LFU de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme LFU de remplacement des lignes de cache]] ===LRU : least recently used=== Avec l'algorithme LRU, la donnée remplacée est celle qui a été utilisée le moins récemment. Cet algorithme se base sur le principe de localité temporelle, qui stipule qu'une donnée accédée récemment a de fortes chances d'être réutilisée dans un futur proche. Et inversement, la donnée la moins récemment utilisée du cache est celle qui a le plus de chance de ne servir à rien dans le futur. Autant la supprimer en priorité pour faire de la place à des données potentiellement utiles. Implémenter l'algorithme LRU peut se faire de différentes manières, qui ont pour point commun d'enregistrer les accès au cache pour en déduire la ligne la moins récemment accédée. La manière la plus simple demande d'utiliser un compteur pour chaque ligne de mémoire cache, un peu comme le LFU. La différence avec le LFU est que le compteur n'est pas incrémenté lors d'un accès mémoire. À la place, ce compteur est incrémenté régulièrement, chaque incrémentation ayant lieu en même temps pour tous les compteurs. Quand un bloc est chargé dans le cache, ce compteur est mis à zéro. Quand une ligne de cache doit être remplacée, un circuit va vérifier la valeur de tous les compteurs : la ligne LRU (la moins récemment utilisée), est celle dont le compteur a la valeur la plus haute. Le circuit est composé d'un paquet de comparateurs, et d'un encodeur, comme pour l'agorithme LFU. ===Les approximations du LRU=== Implémenter le LRU demande un nombre de transistors proportionnel au carré du nombre de lignes de cache. Autant dire que le LRU devient impraticable sur de gros caches. Ce qui fait que les processeurs modernes implémentent des variantes du LRU, moins couteuses en transistors, qui donnent un résultat approximativement semblable au LRU. En clair, ils ne sélectionnent pas toujours la ligne de cache la moins récemment utilisée, mais une ligne de cache parmi les moins récemment utilisées. Ce n'est pas un problème si grave que cela car les lignes les moins récemment utilisées ont toutes assez peu de chance d'être utilisées dans le futur. Entre choisir de remplacer une ligne qui a 0,5 % de chances d'être utilisée dans le futur et une autre qui a une chance de seulement 1 %, la différence est négligeable en termes de taux de succès. Mais les gains en termes de circuits ou de temps d'accès au cache de ces algorithmes sont très intéressants. L'algorithme le plus simple consiste à couper le cache (ou chaque voie s'il est associatif) en plusieurs sections. L'algorithme détermine la section la moins récemment utilisée, avant de choisir aléatoirement une ligne de cache dans cette section. Pour implémenter cet algorithme, il nous suffit d'un registre qui mémorise le morceau le moins récemment utilisé, et d'un circuit qui choisit aléatoirement une ligne de cache. Cette technique s'adapte particulièrement bien avec des caches associatifs à voies : il suffit d'utiliser autant de morceaux que de voies. Autre algorithme, un peu plus efficace : le '''pseudo-LRU de type M'''. Cet algorithme attribue un bit à chaque ligne de cache, bit qui sert à indiquer de façon approximative si la ligne de cache associée est une candidate pour un remplacement ou non. Il vaut 1 si la ligne n'est pas une candidate pour un remplacement et zéro sinon. Le bit est mis à 1 lorsque la ligne de cache associée est lue ou écrite. Évidemment, au fil du temps, toutes les lignes du cache finiront par avoir leur bit à 1. Lorsque cela arrive, l'algorithme remet tous les bits à zéro, sauf pour la dernière ligne de cache accédée. L'idée derrière cet algorithme est d'encercler la ligne de cache la moins récemment utilisée au fur et à mesure des accès. L'encerclement commence lorsque l'on remet tous les bits associés aux lignes de cache à 0, sauf pour la ligne accédée en dernier. Au fur et à mesure des accès, l'étau se resserre autour de la ligne de cache la moins récemment utilisée. Après un nombre suffisant d'accès, l'algorithme donne une estimation particulièrement fiable. Et comme les remplacements de lignes de cache sont rares comparés aux accès aux lignes, cet algorithme finit par donner une bonne estimation avant qu'on ait besoin d'effectuer un remplacement. Le dernier algorithme d'approximation, le '''PLURt''', se base sur ce qu'on appelle un arbre de décision. Il a besoin de n − 1 bits pour déterminer la ligne LRU. Ces bits doivent être organisés en arbre, comme illustré plus bas. Chacun de ces bits sert à dire : le LRU est à ma droite ou à ma gauche : il est à gauche si je vaux 0, et à droite si je vaux 1. Trouver le LRU se fait en traversant cet arbre, et en interprétant les bits un par un. Au fur et à mesure des lectures, les bits sont mis à jour dans cet arbre, et pointent plus ou moins bien sur le LRU. La mise à jour des bits s'effectue lors des lectures et écritures : quand une ligne est lue ou écrite, elle n'est pas la ligne LRU. Pour l'indiquer, les bits à 1 qui pointent vers la ligne de cache sont mis à 0 lors de la lecture ou écriture. {| |[[File:Organisation des bits avec l'algorithme PLURt.jpg|vignette|Organisation des bits avec l'algorithme PLURt.]] |[[File:Ligne de cache pointée par les bits de l'algorithme.png|vignette|Ligne de cache pointée par les bits de l'algorithme.]] |} ===LRU amélioré=== L'algorithme LRU, ainsi que ses variantes approximatives, sont très efficaces tant que le programme respecte relativement bien la localité temporelle. Par contre, Le LRU se comporte assez mal dans les circonstances ou la localité temporelle est mauvaise mais où la localité spatiale est respectée, le cas le plus emblématique étant le parcours d'un tableau. Pour résoudre ce problème, des variantes du LRU existent. Une variante très connue, l''''algorithme 2Q''', utilise deux caches : un cache FIFO pour les données accédées une seule fois et un second cache LRU. Évidemment, les données lues une seconde fois sont migrées du cache FIFO vers le cache LRU, ce qui n'est pas très pratique. Les processeurs n'utilisent donc pas cette technique, mais celle-ci est utilisée dans les caches de disque dur. D'autres variantes du LRU combinent plusieurs algorithmes à la fois et vont choisir lequel de ces algorithmes est le plus adapté à la situation. Notre cache pourra ainsi détecter s’il vaut mieux utiliser du MRU, du LRU, ou du LFU suivant la situation. ==Les écritures dans le cache : gestion et optimisations== Les écritures se font à une adresse mémoire bien précise, qui peut ou non être chargée dans le cache. Si la donnée à écrire est chargée dans le cache, elle est modifiée directement dans le cache, mais elle ne l'est pas forcément en mémoire RAM. Suivant le processeur, les écritures sont ou non propagées en mémoire RAM. Il existe deux stratégies d'écritures, appelées respectivement le ''write-back'' et le ''write-through''. Avec un cache ''write-back'', si la donnée à mettre à jour est présente dans le cache, on écrit dans celui-ci sans écrire dans la mémoire RAM. Dans ces conditions, une donnée n'est enregistrée en mémoire que si celle-ci quitte le cache, ce qui évite de nombreuses écritures mémoires inutiles. [[File:Cache write-through.png|centre|vignette|upright=2|Cache write-through.]] Avec les caches '''Write-Through''', toute écriture dans le cache est propagée en RAM. Cette stratégie augmente le nombre d'écritures dans la mémoire RAM, ce qui peut saturer le bus reliant le processeur à la mémoire. Les performances de ces caches sont donc légèrement moins bonnes que pour les caches ''write back''. Par contre, ils sont utiles dans les architectures avec plusieurs processeurs, comme nous le verrons dans les chapitres sur les architectures multiprocesseurs. [[File:Cache write-back.png|centre|vignette|upright=2|Cache write-back.]] ===Les caches ''Write-through''=== Sans optimisation particulière, on ne peut écrire dans un cache ''write-through'' pendant qu'une écriture en RAM a lieu en même temps : cela forcerait à effectuer deux écritures simultanées, en comptant celle imposée par l'écriture dans le cache. Pour éviter cela, certains caches ''write-through'' intègrent un '''tampon d’écriture''', qui sert de file d'attente pour les écritures en RAM. C'est une mémoire FIFO dans laquelle on place temporairement les données à écrire en RAM, où elles attendent en attendant que la RAM soit libre. Grâce à lui, le processeur peut écrire dans un cache même si d'autres écritures sont en attente dans le tampon d'écriture. Par souci d'efficacité, des écritures à la même adresse en attente dans le tampon d’écriture sont fusionnées en une seule. Cela fait un peu de place dans le tampon d’écriture, et lui permet d'accumuler plus d'écritures avant de devoir bloquer le cache. Il est aussi possible de fusionner des écritures à adresses consécutives de la mémoire en une seule écriture en rafales. Dans les deux cas, on parle de '''combinaison d'écriture'''. Mais la technique du tampon d'écriture a cependant un léger défaut qui se manifeste dans une situation bien précise : quand le processeur veut lire une donnée en attente dans le tampon d’écriture. La première manière de gérer cette situation est de mettre en attente la lecture tant que la donnée n'a pas été écrite en mémoire RAM. On peut aussi lire la donnée directement dans le tampon d'écriture, cette optimisation portant le nom de '''''store-to-load forwading'''''. Dans tous les cas, il faut détecter le cas où une lecture accède à une donnée dans le tampon d'écriture. À chaque lecture, l'adresse à lire est envoyée au tampon d'écriture, qui vérifie si une écriture en attente se fait à cette adresse. Pour cela, le tampon d’écriture doit être un cache, dont chaque entrée mémorise une écriture. Chaque ligne de cache contient la donnée à écrire, et le tag de la ligne de cache contient l'adresse où écrire la donnée. Notons que cache d'écriture a une politique de remplacement de type FIFO, le tampon d'écriture non-optimisé étant une mémoire FIFO. ===Les caches ''Write-back''=== Les caches ''write-back'' ont beau avoir des performances supérieures à celles des caches ''write-through'', il existe des optimisations qui permettent d'améliorer leurs performances. Ces optimisations consistent à ajouter des caches spécialisés à côté du cache proprement dit. Ces caches permettent de mémoriser des données qui sont éliminées du cache par les algorithmes de remplacement de ligne cache, sans pour autant faire une écriture en RAM. En suivant la procédure habituelle de remplacement des lignes de cache, on doit rapatrier la ligne en RAM avant d'en charger une nouvelle. On peut améliorer la situation en faisant l'inverse : on charge la nouvelle ligne pendant que l'ancienne donnée est rapatriée en RAM. Ainsi, la nouvelle ligne est disponible plus tôt pour le processeur, diminuant son temps d'attente. Pour implémenter cette technique, on doit mémoriser l'ancienne ligne de cache temporairement dans un '''cache d’éviction''' (ou ''write-back buffer''). [[File:Cache d’éviction.png|centre|vignette|upright=2|Cache d’éviction]] Les caches directement adressés ou associatifs par voie possèdent aussi un tampon d’écriture amélioré. Pour limiter les défauts par conflit de ces caches, des scientifiques ont eu l'idée d'insérer un cache pour stocker les données virées du cache. En faisant ainsi, si une donnée est virée du cache, on peut alors la retrouver dans ce cache spécialisé. Ce cache s'appelle le '''cache de victime'''. Ce cache de victime est géré par un algorithme de suppression des lignes de cache de type FIFO. Petit détail : ce cache utilise un tag légèrement plus long que celui du cache directement adressé au-dessus de lui. L'index de la ligne de cache doit en effet être contenu dans le tag du cache de victime, pour bien distinguer deux adresses différentes, qui iraient dans la même ligne du cache juste au-dessus. [[File:Victim Cache Implementation Example.svg|centre|vignette|upright=1|Cache de victime.]] ===La configuration du fonctionnement du cache=== Sur de nombreux processeurs, il est possible de configurer la mémoire cache pour qu'elle fonctionne soit en mode ''write-back'', soit en mode ''write-through''. Pour cela, les processeurs modernes incorporent des '''registres de configuration du cache'''. Le terme ''registre de configuration du cache'' est assez transparent et indique bien quel est leur rôle. Ils configurent comment le cache est utilisé et permettent notamment de configurer le cache pour dire s'il doit fonctionner en mode ''write-back'' ou ''write-through''. Ils permettent aussi d'activer ou de désactiver la combinaison sur écriture. Les registres en question sont configurés soit par le BIOS, soit par le système d'exploitation. Ce sont des registres protégés, que les applications ne peuvent pas configurer, elles n'en ont pas le droit. Typiquement, ils ne sont accessibles en écriture qu'en mode noyau. Sur les processeurs x86, les registres de configuration du cache sont appelés des '''''Memory type range registers''''' (''MTRRs''). Les MTRRs sont assez nombreux, et il y a notamment une différence entre mode réel et protégé. Si vous vous souvenez des chapitres sur le mode d'adressage et la mémoire virtuelle, vous vous souvenez que les processeurs x86 incorporent plusieurs modes de fonctionnement. En mode réel, le processeur ne peut adresser qu'un mébioctet de RAM, avec un système de segmentation particulier. En mode protégé, le processeur peut adresser toute la mémoire et la segmentation fonctionne différemment, quand elle n'est pas simplement désactivée. Les MTRRs sont séparés en deux : ceux pour le mode réel, ceux pour le mode protégé. Les MTRRs fixes sont ceux qui configurent le cache en mode réel, ils étaient utilisés pour gérer l'accès au BIOS, à la mémoire VGA de la carte graphique, et quelques autres accès aux entrées-sorties basiques gérées nativement par le BIOS. Pour le mode protégé, les processeurs au-delà du 386 incorporent des MTRRs variables, qui servent pour les autres entrées-sorties en général, notamment les périphériques PCI, la mémoire vidéo de la carte graphique, et j'en passe. De nos jours, les registres de configuration du cache sont désuets et cette fonctionnalité est gérée directement par la mémoire virtuelle. La table des pages contient, pour chaque page mémoire, des bits de contrôle qui disent si la page mémoire est cacheable ou non. Le contournement de cache est alors géré par le système de mémoire virtuelle, le cache de TLB et tout ce qui va avec. ===L’allocation sur écriture=== Que faire quand une écriture modifie une donnée qui n'est pas dans le cache ? Doit-on écrire la donnée dans le cache, ou non ? Si la donnée est écrite dans le cache, on dit que le cache fait une '''allocation sur l'écriture''' (ou ''write-allocate''). Certains caches effectuent une telle allocation sur écriture, mais d'autres ne le font pas ou du moins pas systématiquement. L’allocation sur écriture peut se décliner en deux sous-catégories : le '''chargement à la demande''' et l''''écriture immédiate'''. Dans le premier cas, on charge la donnée à modifier dans le cache, et on la remplace avec la donnée écrite. Dans l'écriture immédiate, l'écriture a lieu directement dans le cache et la donnée à modifier n'est pas chargée dans le cache. Évidemment, seule une portion de la ligne de cache contient la donnée écrite (valide), et le reste contient des données invalides. Le cache doit savoir quelles sont les portions du cache qui sont valides : cela demande d'utiliser un ''sector cache''. [[File:Write-back with write-allocation.svg|centre|vignette|upright=2|Cache Write-back avec allocation sur écriture.]] Sans allocation sur écriture, l'écriture est transférée directement aux niveaux de cache inférieurs ou à la mémoire si la donnée à modifier n'est pas dans le cache. Certains caches de ce genre utilisent une petite optimisation : lors de toute écriture, ils supposent que l'écriture donnera un succès de cache. Si c'est le cas, la ligne de cache qui contient la donnée est mise à jour avec la donnée à écrire. Mais si ce n'est pas le cas, la ligne de cache est invalidée, et l'écriture est transférée directement à la mémoire ou aux niveaux de cache inférieurs. [[File:Write-through with no-write-allocation.svg|centre|vignette|upright=2|Cache Write-through sans allocation sur écriture.]] ===La cohérence des caches=== Il arrive parfois que la mémoire d'un ordinateur soit mise à jour, sans que les modifications soient répercutées dans les mémoires cache. Dans ce cas, le cache contient une donnée périmée. Or, un processeur doit toujours éviter de se retrouver avec une donnée périmée et doit toujours avoir la valeur correcte dans ses caches : cela s'appelle la '''cohérence des caches'''. Il est possible de se retrouver avec des valeurs périmées dans le cache sur les ordinateurs avec plusieurs processeurs, ou si un périphérique écrit en RAM, les modifications ne sont pas répercutées automatiquement dans les mémoires cache. Pour résoudre ce problème, on peut interdire de charger dans le cache des données stockées dans les zones de la mémoire dédiées aux périphériques. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires cache. Autre solution : utiliser le fait que les périphériques déclenchent une interruption matérielle pour laisser le contrôleur DMA accéder à la mémoire. Dans ce cas, il suffit de vider les caches à chaque interruption matérielle. Le processeur peut le faire automatiquement, ou fournir des instructions pour. ==Le ''cache bypassing'' : contourner le cache== Dans certaines situations, le cache n'est pas utilisé pour certains accès mémoire. Diverses techniques permettent en effet d'effectuer des accès mémoire qui contournent le cache, qui ne passent pas par le cache. Ils sont utilisés quand l'accès en cache fait que des instructions normales ne fonctionnent pas. Par exemple, de tels accès directs à la RAM sont notamment utilisés pour l'implémentation d'instructions atomiques, une classe d'instructions spécifiques utilisées sur les processeurs multicœurs, dont nous parlerons dans plusieurs chapitres. Mais ils sont aussi utilisés pour l'accès aux périphériques, ce que nous allons voir maintenant. ===Accéder aux périphériques demande de contourner le cache=== Pour rappel, un périphérique (au sens d'entrée-sortie) contient des registres d’interfaçage qui ont une adresse au même titre que les cases mémoire. Un périphérique peut à tout instant modifier ses registres d’interfaçage, ce qui se répercute automatiquement dans l'espace d'adressage, mais rien de tout cela n'est transmis au cache. Si les accès aux périphériques passaient par l'intermédiaire du cache, on aurait droit à des problèmes. On aurait encore une fois droit à des problèmes de cohérence des caches. Le problème est géré différemment suivant que l'on utilise un espace d'adressage séparé ou des entrées-sorties mappées en mémoire. La solution est que les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache. Cela demande d'adapter le cache et le processeur. L'implémentation exacte dépend de comment sont adressés les périphériques. Pour rappel, il y a deux solutions pour adresser les périphériques : soit les périphériques disposent d'un espace d'adressage séparé de celui de la mémoire, soit il y un espace d'adressage unique partagé entre processeur et mémoire. Les deux cas donnent des solutions différentes. Avec un espace d'adressage séparé, l'espace d'adressage des périphériques n'est pas caché : aucun accès dans cet espace d'adressage ne passe par le cache. La mémoire cache n'est utilisée que pour l'espace d'adressage des mémoires, rien d'autre. C'est de loin le cas le plus simple : il suffit de concevoir le processeur pour. Il dispose d'instructions séparées pour les accès aux registres d’interfaçage et à la RAM/ROM, les premières ne passent pas par le cache, les autres si. Avec des entrées-sorties mappées en mémoire, la même solution est utilisée, mais dans une version un peu différente. Là encore, les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache, si on veut qu'ils marchent comme ils le doivent. Cela demande d'adapter le cache et le matériel pour que accès aux périphériques mappés en mémoire contournent le cache. Des adresses, voire des zones entières de la mémoire, sont marquées comme étant non-cachables. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires caches. Là encore, le processeur doit être prévu pour : on doit pouvoir le configurer de manière à marquer certaines zones de la RAM comme non-cacheable. Reste qu'il faut marquer des régions de la RAM comme non-cacheable. Pour cela, on améliore les registres de configuration du cache, vus plus haut, afin qu'ils permettent de configurer certaines portions de la RAM pour préciser qu'elles ne doivent pas être mises en cache, qu'il faut activer le contournement de cache pour celles-ci. ===Contourner le cache pour des raisons de performance=== Il arrive que des données avec une faible localité soient chargées dans le cache inutilement. Or, il vaut mieux que ces données transitent directement entre le processeur et la mémoire, sans passer par l'intermédiaire du cache. Pour cela, le processeur peut fournir des instructions d'accès mémoire qui ne passent pas par le cache, à côté d'instructions normales. De telle instructions sont appelées des '''instructions mémoire non-temporelles'''. Non-temporelle, dans le sens : pas de localité temporelle (c.a.d que les données ne seront pas réutilisées plus tard). Mais il existe aussi des techniques matérielles, où le cache détecte à l'exécution les lectures qui gagnent à contourner le cache. La dernière méthode demande d'identifier les instructions à l'origine des défauts de cache, le processeur accédant directement à la RAM quand une telle instruction est détectée. Si une instruction d'accès mémoire fait trop de défauts de cache, c'est signe qu'elle gagne à contourner le cache. L'idée est de mémoriser, pour chaque instruction d'accès mémoire, un historique de ses défauts de cache. Il existe plusieurs méthodes pour cela, mais toutes demandent d'ajouter de quoi mémoriser l'historique des défauts de cache des instructions. L'historique est mémorisé dans une mémoire appelée la '''table d’historique des défauts de lecture''' (''load miss history table''), qui est souvent un cache. L'historique en question est, dans sa version la plus simple, un compteur de quelques bits incrémenté à chaque succès de cache et décrémenté à chaque défaut de cache, qui indique si l'instruction a en moyenne fait plus de défauts ou de succès de cache. La table associe le ''program counter'' d'une instruction mémoire à cet historique. À la première exécution d'une instruction d'accès mémoire, une entrée de cette table est réservée pour l'instruction. Lors des accès ultérieurs, le processeur récupérer les informations associées et décide s'il faut contourner le cache ou non. ==La hiérarchie mémoire des caches== [[File:Cache Hierarchy.png|vignette|Hiérarchie de caches]] On pourrait croire qu'un seul cache est largement suffisant pour compenser la lenteur de la mémoire. Hélas, les processeurs sont devenus tellement rapides que les caches sont eux-mêmes très lents ! Pour rappel, plus une mémoire peut contenir de données, plus elle est lente. Et les caches ne sont pas épargnés. Si on devait utiliser un seul cache, celui-ci serait très gros et donc trop lent. La situation qu'on cherche à éviter avec la mémoire RAM revient de plus belle. Même problème, même solution : si on a décidé de diviser la mémoire principale en plusieurs mémoires de taille et de vitesse différentes, on peut bien faire la même chose avec la mémoire cache. Depuis environ une vingtaine d'années, un processeur contient plusieurs caches de capacités très différentes : les caches L1, L2 et parfois un cache L3. Certains de ces caches sont petits, mais très rapides : c'est ceux auxquels on va accéder en priorité. Viennent ensuite d'autres caches, de taille variable, mais plus lents. Les processeurs ont donc une hiérarchie de caches qui se fait de plus en plus complexe avec le temps. Cette hiérarchie est composée de plusieurs niveaux de cache, qui vont des niveaux inférieurs proches de la mémoire RAM à des niveaux supérieurs proches du processeur. Plus on monte vers les niveaux supérieurs, plus les caches sont petits et rapides. Un accès mémoire dans une hiérarchie de cache fonctionne comme suit : on commence par vérifier si la donnée recherchée est dans le cache le plus rapide, à savoir le cache L1. Si c'est le cas,n on la charge depuis ce cache directement. Si elle n’y est pas, on vérifie si elle est dans le cache de niveau supérieur, le cache L2. Et rebelote ! Si elle n'y est pas, on vérifie le cache du niveau supérieur. Et on répète cette opération, jusqu’à avoir vérifié tous les caches. Si la donnée n'est dans aucun cache, on doit alors aller chercher la donnée en mémoire. [[File:Hiérarchie de caches.png|centre|vignette|upright=2|Hiérarchie de caches]] Il y a des différences assez notables entre chaque niveau de cache. Par exemple, les différents niveaux de cache n'ont pas forcément les mêmes politiques de remplacement des lignes de cache. Le cache L1 a généralement une politique de remplacement simple, très rapide, mais peu efficace. De même, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Le cache le plus proche de la mémoire est appelé le '''cache de dernier niveau''', ''Last Level Cache'' en anglais. Il a parfois des caractéristiques totalement différentes des autres caches. Par exemple, sur les processeurs multicoeurs, le cache L3 n'est techniquement pas dans le cœur, mais fait partie d'un ensemble de circuits reliés, comme le contrôleur mémoire ou l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc. ===Les caches exclusifs et inclusifs=== Notons que du point de vue de cette vérification, il faut distinguer les caches inclusifs et exclusifs. Avec les caches inclusifs, si une donnée est présente dans un cache, alors elle est présente dans les caches des niveaux inférieurs, ce qui implique l'existence de données en doublon dans plusieurs niveaux de cache. À l'opposé, les caches exclusifs font que toute donnée est présente dans un seul cache, pas les autres. Il existe aussi des caches qui ne sont ni inclusifs, ni exclusifs. Sur ces caches, chaque niveau de cache gère lui-même ses données, sans se préoccuper du contenu des autres caches. Pas besoin de mettre à jour les niveaux de cache antérieurs en cas de mise à jour de son contenu, ou en cas d'éviction d'une ligne de cache. La conception de tels caches est bien plus simple. Dans les '''caches exclusifs''', le contenu d'un cache n'est pas recopié dans le cache de niveau inférieur. Il n'y a pas de donnée en double et on utilise 100 % de la capacité du cache, ce qui améliore le taux de succès. Par contre, le temps d'accès est un peu plus long. La raison est que si une donnée n'est pas dans le cache L1, on doit vérifier l'intégralité du cache L2, puis du cache L3. De plus, assurer qu'une donnée n'est présente que dans un seul cache nécessite aux différents niveaux de caches de communiquer entre eux pour garantir que l'on a pas de copies en trop d'une ligne de cache, ce qui peut prendre du temps. [[File:Caches exclusifs.png|centre|vignette|upright=2|Caches exclusifs]] Dans le cas des '''caches inclusifs''', le contenu d'un cache est recopié dans les caches de niveau inférieur. Par exemple, le cache L1 est recopié dans le cache L2 et éventuellement dans le cache L3. Ce genre de cache a un avantage : le temps d'accès à une donnée est plus faible. La raison est qu'il ne faut pas vérifier tout un cache, mais seulement la partie qui ne contient pas de donnée en doublon. Par exemple, si la donnée voulue n'est pas dans le cache L1, on n'est pas obligé de vérifier la partie du cache L2 qui contient la copie du L1. Ainsi, pas besoin de vérifier certaines portions du cache, ce qui est plus rapide et permet de simplifier les circuits de vérification. En contrepartie, l'inclusion fait que qu'une partie du cache contient des copies inutiles, comme si le cache était plus petit. De plus, maintenir l'inclusion est compliqué et demande des circuits en plus et/ou des échanges de données entre caches. [[File:Caches inclusifs.png|centre|vignette|upright=2|Caches inclusifs]] Maintenir l'inclusion demande de respecter des contraintes assez fortes, ce qui ne se fait pas facilement. Premièrement, toute donnée chargée dans un cache doit aussi l'être dans les caches de niveau inférieur. Ensuite, quand une donnée est présente dans un cache, elle doit être maintenue dans les niveaux de cache inférieurs. De plus, toute donnée effacée d'un cache doit être effacée des niveaux de cache supérieurs : si une donnée quitte le cache L2, elle doit être effacée du L1. Ces trois contraintes posent des problèmes si chaque cache décide du remplacement des lignes de cache en utilisant un algorithme comme LRU, LFU, MRU, ou autre, qui utilise l'historique des accès. En effet, dans ce cas, le cache décide de remplacer les lignes de cache selon l'historique des accès, historique qui varie suivant chaque niveau de cache. Par exemple, une donnée rarement utilisée dans le L2 peut parfaitement être très fréquemment utilisée dans le L1 : la donnée sera alors remplacée dans le L2, mais sera maintenue dans le L1. On observe aussi des problèmes quand il existe plusieurs caches à un seul niveau : chaque cache peut remplacer les lignes de cache d'une manière indépendante des autres caches du même niveau, donnant lieu au même type de problème. Pour maintenir l'inclusion, les caches doivent se transmettre des informations qui permettent de maintenir l'inclusion. Par exemple, les caches de niveaux inférieurs doivent prévenir les niveaux de cache supérieurs quand ils remplacent une ligne de cache. De plus, toute mise à jour dans un cache doit être répercutée dans les niveaux de cache inférieurs et/ou supérieurs. On doit donc transférer des informations de mise à jour entre les différents niveaux de cache. Généralement, le contenu des caches d'instruction n'est pas inclus dans les caches de niveau inférieurs, afin d'éviter que les instructions et les données se marchent sur les pieds. Enfin, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Dans ce cas, l'inclusion est plus difficile à maintenir, pour des raisons assez techniques. ===Les caches eDRAM, sur la carte mère et autres=== D'ordinaire, les mémoires caches sont intégrées au processeur, à savoir que cache et CPU sont dans le même circuit imprimé. Les caches sont donc fabriqués avec de la SRAM, seule forme de mémoire qu'on peut implémenter dans un circuit intégré. Intégrer tous les caches dans le processeur est une solution et efficace. Mais certains processeurs ont procédé autrement. [[File:Cache-on-a-stick module.jpg|vignette|Cache-on-a-stick module]] Des processeurs assez anciens incorporaient un cache L1 dans le processeur, mais plaçaient un cache L2 sur la carte mère. Le cache était clippé sur un connecteur sur la carte mère, un peu comme le sont les barrettes de mémoire. On parlait alors de '''''Cache on a stick''''' (COAST). On aurait pu s'attendre à ce que de tels caches soient en DRAM, vu qu'ils sont placés sur des barrettes de RAM, mais la ressemblance avec la mémoire RAM principale s'arrête là. Le cache était fabriqué en mémoire SRAM, même s'il est en théorie possible de faire de tels caches avec de la DRAM. Les premiers processeurs avec un cache faisaient ainsi, au début des années 90. Il a été introduiot sur les processeurs Motorola, et a été utilisé sur les IBM PC et les Macintosh de l'époque. Les ordinateurs Macintosh utilisaient de tels caches, pour la pluaprt des modèles. Pour ce qui est des PC, les premiers processeurs x86 faisaient pareil, notamment les processeurs Intel. Le 486, le Pentium et le Pentium 2 utilisaient des ''Cache on a stick''. L'avantage est que cela permettait de mettre plus de cache, à une époque où les circuits étaient limités en transistors. De plus, cela permettait au consommateur de choisir quelle quantité de cache il voulait, selon ses finances. Il était possible de laisser le processeur fonctionner soit sans mémoire cache, soit avec un cache de 256 Kibioctets, de 512 Kibioctets, etc. Il était possible d'upgrader le cache si besoin. Pour les CPU Intel, le cache était connecté sur le bus système, au même titre que la mémoire RAM et les entrées-sorties. Il faut dire que les processeurs de l'époque utilisaient un bus système et n'avaient pas de bus mémoire dédié. Mais en théorie, rien n’empêche de connecter le cache sur un bus mémoire dédié. Toujours est-il que les lectures et écritures étaient propagées à la fois dans le cache et la RAM. Les écritures se faisaient dans les deux, systématiquement dans la RAM, mais aussi dans le cache en cas de succès de cache. Les lectures étaient servies soit par le cache en cas de succès de cache, soit par la RAM en cas de défaut de cache. Si le cache répondait en premier, la transaction sur le bus se terminait précocement et l'accès en RAM était abandonné. [[File:Intel486 Иерархия памяти.png|centre|vignette|upright=2.5|Intel486 : le cache était connecté sur le bus système.]] À l'inverse, certains processeurs possédaient un cache fabriqué en mémoire DRAM, et plus précisément avec de la mémoire eDRAM. Le cache n'était pas intégré dans le même circuit imprimé que le processeur, mais profitait d'une architecture en ''chiplet''. Pour rappel, cela veut dire que le processeur est en réalité composé de plusieurs circuits intégré séparés, mais interconnectés et soudés sur un même PCB carré. Avec un cache en eDRAM, le cache avait son propre circuit intégré, séparé du circuit intégré du processeur ou du circuit intégré pour le contrôleur mémoire/IO. Un exemple est celui du cache des processeurs Intel de microarchitecture Broadwell, vus dans ce chapitre dans la section sur les caches splittés. Les tags étaient intégrés dans le circuit intégré du processeur, mais les données étaient mémorisées dans une puce d'eDRAM séparée. La puce eDRAM correspondait en réalité à une DRAM adressable qui servait de DRAM pour les données et mémorisaient les voies du cache. ==Les caches adressés par somme et hashés== Les caches adressés par somme sont optimisés pour incorporer certains calculs d'adresse directement dans le cache lui-même. Pour rappel, certains modes d'adressage impliquent un calcul d'adresse, qui ajoute une constante à une adresse de base. Généralement, l'adresse de base est l'adresse d'un tableau ou d'une structure, et la constante ajoutée indique la position de la donnée dans le tableau/la structure. Les caches hashés et les caches adressés par somme permettent de faire l'addition directement dans la mémoire cache. Voyons d'abord les caches hashés, avant de passer aux caches adressés par somme. Sur les '''caches hashés''', l'addition est remplacée par une autre opération, par exemple des opérations bit à bit du style XOR, AND ou OR, etc. Seulement, utiliser des opérations bit à bit pose un problème : il arrive que deux couples Adresse/décalage donnent le même résultat. Par exemple, le couple Adresse/décalage 11101111/0001 donnera la même adresse que le couple 11110000/0000. Dit autrement, deux adresses censées être différentes (après application du décalage) sont en réalité attribuées à la même ligne de cache. Il est toutefois possible de gérer ces situations, mais cela demande des astuces de haute volée pour faire fonctionner la mémoire cache correctement. Sur les '''caches adressés par somme''', le décodeur est modifié pour se passer de l'addition. Pour comprendre comment, il faut rappeler qu'un décodeur normal est composé de comparateurs, qui vérifient si l'entrée est égale à une constante bien précise. Sur un cache ordinaire, l'addition est faite séparément du décodage des adresses par le cache, dans l'unité de calcul ou dans l'unité de génération d'adresse. [[File:Non sum adressed cache.png|centre|vignette|upright=2|Cache normal.]] Mais les caches adressés par somme modifient le décodeur, qui est alors composé de comparateurs qui testent si la somme adresse + décalage est égale à une constante. [[File:Cache adressé par somme.png|centre|vignette|upright=2|Cache adressé par somme.]] Chaque circuit du décodeur fait le test suivant, avec K une constante qui dépend du circuit : : <math>A + B = K</math> Ce qui est équivalent à faire le test suivant : : <math>A + B - K = 0</math> En complément à deux, on a <math>- K = \overline{K} + 1</math>. En injectant dans l'équation précédente, on a : : <math>A + B + \overline{K} + 1 = 0</math> En réorganisant les termes, on a : : <math>A + B + \overline{K} = - 1</math> Il suffit d'utiliser un additionneur ''carry-save'' pour faire l'addition des trois termes. Rappelons qu'un tel additionneur fournit deux résultats en sortie : une somme calculée sans propager les retenues et les retenues en question. Notons que les retenues sont à décaler d'un cran, vu qu'elles sont censées s'appliquer à la colonne suivante. En notant la somme S et les retenues R, on a: : <math>S + (R << 1) = - 1 </math>, le décalage d'un cran à gauche étant noté <math><< 1</math>. Ensuite, -1 est codé avec un nombre dont tous les bits sont à 1 en complément à un/deux. : <math>S + (R << 1) = 111 \cdots 111111</math> [[File:Sum + retenue add.png|centre|vignette|upright=2|Sum + retenue add]] Un simple raisonnement nous permet de savoir si le résultat est bien -1, sans faire l'addition <math>S + (R << 1)</math>. En effet, on ne peut obtenir -1 que si la somme est l'inverse des retenues : un 0 dans le premier nombre correspond à un 1 dans l'autre, et réciproquement. En clair, on doit avoir <math>\overline{S} = R << 1</math>. Pour vérifier cela, il suffit de faire un simple XOR entre la somme et les retenues décalées d'un cran. On a alors : : <math>S \oplus (R << 1) = 111 \cdots 111111</math> La comparaison avec -1 se fait avec une porte ET à plusieurs entrées. En effet, la porte donnera un 1 seulement si tous les bits d'entrée sont à 1, ce qui est ce qu'on veut tester. Au final, l'additionneur pour l'addition adresse + décalage est remplacé par un additionneur carry-save suivi d'une couche de portes XOR et d'un comparateur avec une constante, ce qui économise de circuits et améliore les performances. [[File:Final circuit of sum addressed cache.png|centre|vignette|upright=2|Cache adressé par somme.]] En prenant en compte que la constante K est justement une constante, certaines entrées de l'additionneur carry-save sont toujours à 0 ou à 1, ce qui permet quelques simplifications à grand coup d’algèbre de Boole. Chaque additionneur complet qui compose l’additionneur carry-save est remplacée par des demi-additionneurs (ou par un circuit similaire). Autant dire que l'on gagne tout de même un petit peu en rapidité, en supprimant une couche de portes logiques. Le circuit de décodage économise aussi des portes logiques, ce qui est appréciable. ==Les caches à accès uniforme et non-uniforme== Intuitivement, le temps d'accès au cache est le même pour toutes les lignes de cache. Il s'agit de cache appelés '''caches à accès uniforme''', sous-entendu à temps d'accès uniforme. Mais sur les caches de grande capacité, il arrive souvent que le temps de propagation des signaux varie fortement suivant la ligne de cache à lire. D'ordinaire, on se cale sur la ligne de cache la plus lente pour caler la fréquence d'horloge du cache, même si on pourrait faire mieux. Cependant, les '''caches à accès non uniforme''' ont une latence différente pour chaque ligne d'un même cache. Certaines lignes de cache sont plus rapides que d'autres. Niveau terminologie, nous allons parler de caches UCA et NUCA : ''Uniform Access Cache'' pour les caches à accès uniforme, ''Non-Uniform Access Cache'' pour les caches à accès non-uniforme. [[File:Caches UCA et NUCA.png|vignette|Caches UCA et NUCA.]] Les caches NUCA et UCA sont souvent composés de plusieurs banques séparées, typiquement une par voie. Sur les caches UCA, les banques sont interconnectées avec le processeur de manière à ce que toutes les interconnexions ont la même longueur pour toutes les banques. Typiquement, les banques sont organisées en carré, avec les interconnexions qui partent du centre, avec une disposition en H, illustrée ci-contre Mais avec les caches NUCA, ce n'est pas le cas. Les interconnexions sont simplifiées et ont des longueurs différentes. Les caches NUCA n'ont pas tous le même genre d'interconnexions, qui dépendent du cache NUCA. En général, les interconnexion forme un réseau avec des sortes de routeurs qui redirigent les données/commandes vers la bonne destination : cache ou processeur. Les banques plus proches du processeur sont accessibles plus rapidement que celles éloignées, même si la différence n'est pas énorme. Les caches NUCA sont généralement associatifs par voie. Les plus simples utilisent une banque par voie pour le cache, ce qui fait que certaines voies répondent plus vite que les autres. La détection des succès de cache est alors plus rapide si la donnée lue/écrite est dans une voie/banque rapide. En théorie, les défauts de cache demandent de vérifier toutes les banques, et se calent donc sur la pire latence. Mais divers caches se débrouillent pour que ce ne soit pas le cas, soit en vérifiant les banquyes unes par une, soit par un mécanisme de recherche plus complexe. Les caches NUCA sont surtout utilisés pour les caches L3 et L4, éventuellement les caches L2. Les caches L1 sont systématiquement des caches UCA, car la latence de l'accès au cache L1 est utilisée par le processeur pour décider quand lancer les instructions. Pour simplifier, le processeur peut démarrer en avance une instruction avant qu'une opérande soit lue dans le cache L1, de manière à ce que la donnée arrive en entrée de l'ALU pile en même temps que l'instruction. Une histoire d'exécution dans le désordre et d'émission anticipée des instructions qu'on détaillera dans une bonne dizaine de chapitres. Toujours est-il que tout est plus simple pour le processeur si le cache L1 a un temps d'accès fixe. Par contre, les caches L3 et L4 sont traités en attendant que les données arrivent, le processeur reprend l'exécution des instructions quand les caches L3 et L4 ont terminé de répondre, pas avant. Avec l'association une banque = une voie, la correspondance ligne de cache → bloc de mémoire qui est statique : on ne peut pas déplacer le contenu d'une ligne de cache dans une autre portion de mémoire plus rapide suivant les besoins. Mais la recherche académique a étudié le cas où la correspondance entre une ligne de cache et une banque varie à l’exécution. Pour nommer cette distinction, on parle de caches S-NUCA (''Static NUCA'') et D-NUCA (''Dynamic NUCA''). Intuitivement, on s'attend à ce que les caches D-NUCA soient plus performants que les caches S-NUCA. Les lignes de cache les plus utilisées peuvent migrer dans une banque rapide, alors que les lignes de cache moins utilisées vont dans une banque éloignée. Les lignes de cache se répartissent dans le cache dynamiquement dans les banques où elles sont le plus adaptées. Mais paradoxalement, le gain des caches D-NUCA est presque nul, voire insignifiant. La raison est que les caches D-NUCA doivent incorporer un système pour déterminer dans quelle banque se situe la donnée pour détecter les succès/défauts de cache, ainsi qu'un système pour migrer les données entre banques. Et ce système augmente le temps d'accès au cache, réduisant à néant l'intérêt d'un cache D-NUCA. Si on économise quelques microsecondes de temps d'accès en passant d'un cache UCA à un cache S-NUCA, ce n'est pas pour les perdre en passant à un D-NUCA. La majorité des caches D-NUCA sont donc en cours de recherche, mais ne sont pas utilisés en pratique. ==La tolérance aux erreurs des caches== Une mémoire cache reste avant tout une mémoire RAM, bien que ce soit de la SRAM. Elle n'est pas parfaite et est donc sujette à des erreurs, qui peuvent inverser un bit ou l'effacer. De telles erreurs sont liées à des rayons cosmiques très énergétiques, à des particules alpha produites par le packaging ou le métal deu circuit intégré, peu importe : l'essentiel est qu'ils inversent parfois un bit. Les mémoires modernes savent se protéger contre de telles erreurs, en utilisant trois moyens. ===Les mémoires caches ECC et à bit de parité=== Le premier moyen est l'usage de codes correcteurs d'erreurs, qui ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Les bits ajoutés dépendent de la donnée mémorisée dans le byte, et servent à détecter une erreur, éventuellement à la corriger. Le cas le plus simple ajoute un simple bit de parité pour chaque byte et se contente de détecter les erreurs dans les corriger. Les autres codes ECC permettent eux de corriger des erreurs, mais ils demandent d'ajouter au moins deux bits par byte, ce qui a un cout en circuit plus élevé. Un simple bit de parité permet de détecter qu'un bit a été inversé, mais ne permet pas de corriger l'erreur. En soi, ce n'est pas un problème. Si une erreur est détectée, on considère que la ligne de cache est invalide. Le cache gère la situation comme un défaut de cache et va chercher la donnée valide en mémoire RAM. Le cout en circuits est donc faible, mais les défauts de cache sont plus nombreux. Les codes ECC sont eux capables de corriger les erreurs, si elles ne modifient pas trop de bits d'un coup. Par contre, ils utilisent deux à trois bits par octet, ce qui a un cout en circuits loin d'être négligeable. Il y a donc un compromis entre défauts de cache et cout en circuits. La gestion de l'ECC est différente suivant le niveau de cache. Généralement, le cache L1 n'utilise pas l'ECC mais se contente d'un simple bit de parité pour éviter la corruption de ses données. Le cache étant petit, les corruptions de données sont assez rares, et les défauts de cache induits faibles. Il est plus important d'utiliser un code de détection d'erreur simple, rapide, qui ne ralentit pas le cache et n'augmente pas sa latence. Si une ligne de cache est corrompue, il a juste à aller lire la ligne depuis le cache L2, ou un niveau de cache inférieur. Du moins, c'est possible sur le cache en question est un cache inclusif et/ou ''write-through''. Par contre, le niveau de cache L2 et ceux en-dessous utilisent presque systématiquement une mémoire SRAM ECC. La raison principale étant que ce sont des caches assez gros, pour lesquels la probabilité d'une erreur est assez élevée. Plus une mémoire a de bits et prend de la place, plus il y a une chance élevée qu'un bit s'inverse. Et vu que les caches L2/L3/L4 sont par nature plus lents et plus gros, ils peuvent se permettre le cout en performance lié à l'ECC, idem pour le cout en circuit. Sans compter qu'en cas d'erreur, ils doivent aller lire la ligne de cache originelle en mémoire RAM, ce qui est très lent ! Mieux vaut corriger l'erreur sur place en utilisant l'ECC. ===L'usage du ''memory scrubbing'' sur les caches=== La plupart des erreurs ne changent qu'un seul bit dans un byte, mais le problème est que ces erreurs s'accumulent. Entre deux accès à une ligne de cache, il se peut que plusieurs erreurs se soient accumulées, ce qui dépasse les capacités de correction de l'ECC. Dans ce cas, il existe une solution appelée le ''memory scrubbing'', qui permet de résoudre le problème au prix d'un certain cout en performance. Pour rappel, l'idée est de vérifier les lignes de caches régulièrement, pour éviter que les erreurs s'accumulent. Par exemple, on peut vérifier chaque ligne de cache toutes les N millisecondes, et corriger une éventuelle erreur lors de cette vérification. En faisant des vérifications régulières, on garantir que les erreurs n'ont pas le temps de s'accumuler, sauf en cas de malchance avec des erreurs très proches dans le temps. Il ne s'agit pas d'un rafraichissement mémoire, car les SRAM ne s'effacent pas), mais ça a un effet similaire. Et évidemment, le ''memory scrubbing'' a un cout en performance. On peut faire une comparaison avec le rafraichissement mémoire : les rafraichissement réguliers réduisent les performances, car cela fait des accès en plus. Des accès qui sont de plus timés à des instants bien précis qui ne sont pas forcément les plus adéquats. Il est possible qu'un rafraichissement ait lieu en même temps qu'un accès mémoire et le rafraichissement a la priorité, ce qui réduit les performances. La même chose arrive avec les vérifications du ''memory scrubbing''. Malgré tout, la technique a été utilisée sur les caches de certains processeurs commerciaux, dont des processeurs AMD Athlon et Athlon 64. Elle est surtout utilisable sur les caches L2/L3, pour lesquels le cout du pseudo-rafraichissement est acceptable. ==Un exemple de cache : le cache d'instruction== La grande majorité des processeurs utilise deux caches L1 séparés : un '''cache d'instructions''' dédié aux instructions, et un autre pour les données. Une telle organisation permet de charger une instruction tout en lisant une donnée en même temps. Notons que seul le cache L1 est ainsi séparé entre cache de données et d'instructions. Le cache d’instruction se situe en théorie entre l'unité de chargement et l'unité de décodage. En effet, ce cache prend en entrée une adresse et fournit une instruction. L'adresse est fournie par le ''program counter'', l'instruction est envoyée dans l'unité de décodage. Le cache se situe donc entre les deux. Le cache de données L1 est connecté au chemin de données, et notamment aux unités de communication avec la mémoire, pas au séquenceur. [[File:Caches L1 et positions dans le processeur.png|centre|vignette|upright=2.5|Caches L1 et positions dans le processeur]] Les deux caches sont reliés au processeur par des bus séparés, l'ensemble ressemble à une architecture Harvard, mais où les caches remplacent les mémoires RAM/ROM. Le cache d'instruction prend la place de la mémoire ROM et le cache de données prend la place de la mémoire RAM. Évidemment, il y a des niveaux de caches en dessous des caches de données/instruction, et ceux-ci contiennent à la fois données et instructions, les deux ne sont pas séparées dans des mémoires/caches séparés. Raison pour laquelle l'ensemble est appelé une '''architecture Harvard modifiée'''. Architecture Harvard, car l'accès aux données et instructions se font par des voies séparées pour le processeur, modifiée car la séparation n'est effective que pour le cache L1 et pas les autres niveaux de cache, et encore moins la RAM. Sur les processeurs modernes, il arrive très souvent que le processeur doive charger une instruction et lire/écrire une donnée en même temps. Et à vrai dire, c'est la règle plus que l'exception. L'usage d'une architecture Harvard modifiée permet cela très facilement : on peut accéder au cache d'instruction via un bus, et au cache de donnée avec l'autre ===Pourquoi scinder le cache L1 en cache d'instruction et de données=== L'usage d'un cache d’instruction séparé du cache de données est à contraster avec l'usage d'un cache L1 multiport unique, capable de mémoriser à la fois instructions et données. Les deux solutions sont possibles ont été utilisées. Les premiers processeurs avaient un cache L1 unique et multiport, mais ce n'est plus le cas sur les processeurs modernes, car les contraintes ne sont pas les mêmes. Le compromis à faire est celui entre deux petits caches rapides et un gros cache plus lent. Pour rappel, plus un cache est petit, plus il est rapide et chauffe moins. Donc au lieu d'utiliser, par exemple, un gros cache lent de 64 Kibioctets, on utilise deux caches de 32 kibioctets, plus rapides. La capacité totale est la même, mais le temps d'accès plus faible. En termes de temps d'accès, la meilleure solution est celle des deux caches simple port. Mais pour ce qui est de l'économie de circuits, c'est moins évident. Entre deux mémoires simple port et une mémoire multiport, la différence en termes de transistors est ambigüe et dépend de la capacité des caches. La différence est surtout notable pour les gros caches, moins pour les petits caches. Il faut aussi tenir compte de la capacité effective. Avec deux caches séparés, la répartition de la capacité du cache L1 est fixée une bonne fois pour toutes. Par exemple, avec un cache d'instruction de 32 KB et un cache de données de 32 KB, impossible d'allouer 40 KB aux données et 20 aux instructions. Alors qu'avec un cache L1 unique de 64 KB, on pourrait le faire sans soucis. La répartition se fait naturellement, en fonction de la politique de remplacement du cache et est proche de l'optimal. C'est là un désavantage des caches d'instructions/données séparés : une capacité effective moindre. Tout cela explique pourquoi le cache L1 est le seul à être ainsi scindé en deux, avec une séparation entre instructions et données : les contraintes au niveau du cache L1 et L2 ne sont pas les mêmes. Pour les caches L1, le temps d'accès est plus important que la capacité, ce qui favorise les caches séparés. Par contre, pour les caches L2/L3/L4, le temps d'accès n'est pas déterminant, alors que la capacité effective et l'économie en circuits sont significatives. ===La connexion des caches L1 avec le cache L2=== Pour les connexions avec le cache L2, tout dépend du processeur. Certains utilisent un cache L2 multiport, qui permet aux deux caches L1 de lire ou écrire dans le cache L2 simultanément. [[File:Cache d'instructions.png|centre|vignette|upright=1.5|Cache d'instructions.]] Si le cache L2 ne gère pas les accès simultanés, il n'y a qu'un seul bus relié aux caches L1 et au cache L2. On doit effectuer un arbitrage pour décider quel cache a la priorité, chose qui est réalisé par un circuit d'arbitrage spécialisé. [[File:Circuit d'arbitrage du cache.png|centre|vignette|upright=1.5|Circuit d'arbitrage du cache.]] Généralement, les caches d'instructions peuvent se permettre d'être plus petits que les caches de données, car les programmes sont souvent plus petits que les données manipulées. Songez que des programmes de quelques mébioctets peuvent parfois remplir la RAM avec plusieurs gibioctets de données. Lancez votre navigateur internet et ouvrez une page web un peu chargée, pour vous en convaincre ! ===Les spécificités du cache d'instruction : lecture seule, bloquant, etc=== Les instructions sont rarement modifiées ou accédées en écritures, contrairement aux données. Et cela permet d'utiliser un cache simplifié pour les instructions. Autant un cache généraliste doit permettre les lectures et écritures depuis le processeur (avec les échanges avec la RAM), autant un cache d'instruction peut se contenter des lectures provenant du CPU et des échanges avec la RAM. Le cache d'instructions est donc très souvent en « lecture seule » : le processeur ne peut pas écrire dedans, mais juste le lire ou charger des instructions dedans. Un cache d'instruction est donc plus simple qu'un cache pour les données : on peut retirer les circuits en charge de l'écriture (mais on doit laisser un port d'écriture pour charger les instructions dedans). Le gain en circuits permet d'utiliser un cache d'instruction plus gros ou au contraire de laisser de la place pour le cache de données. Le gain en termes de capacité compense alors un peu les inconvénients des caches séparés. Par contre, cela complique la gestion du code automodifiant, c'est-à-dire des programmes dont certaines instructions vont aller en modifier d'autres, ce qui sert pour faire de l'optimisation ou est utilisé pour compresser ou cacher un programme (les virus informatiques utilisent beaucoup de genre de procédés). Quand le processeur exécute ce genre de code, il ne peut pas écrire dans ce cache L1 d'instructions, mais doit écrire dans le cache L2 ou en RAM, avant de recharger les instructions modifiées dans le cache L1. Cela qui prend du temps et peut parfois donner lieu à des erreurs si le cache L1 n'est pas mis à jour. Les algorithmes de remplacement des lignes de cache optimaux pour les données ne le sont pas pour les instructions, de même que la taille optimale du cache, la taille des lignes de cache optimale, ou même les algorithmes de préchargement. Par exemple, pour le remplacement des lignes de cache, un simple algorithme LRU est presque optimal pour les instructions, autant il peut donner de mauvaises performances quand on manipule beaucoup de tableaux. Cela justifie d'utiliser des caches spécialisés pour chacune. On peut adapter le cache d'instruction à son contenu, ce qui le rend plus rapide ou plus petit à performance égale. Les caches d'instructions sont généralement des caches bloquants. Il ne servirait à rien de rendre un cache d'instruction non-bloquant, le cout en circuits ne se traduirait pas par une augmentation significative des performances. À l'opposé, les caches de données sont non-bloquants sur les architectures modernes, pour des raisons de performance. Ce qui rend la séparation assez intéressante, les deux caches ayant des besoins différents et des implémentations différentes, cela permet d'optimiser le cout en transistors des caches. ===L'impact du cache d'instruction sur les performances=== Sur les architectures conventionnelles, le cache d'instruction a plus d'impact sur les performances que le cache de données. La raison principale est que les instructions ont une meilleure localité spatiale et temporelle que pour les données. Pour la localité spatiale, les instructions consécutives se suivent en mémoire, alors que rien ne garantit que des données utilisées ensemble soient regroupées en mémoire. Pour localité temporelle, elle est très variable pour les données, mais très courante pour les instructions du fait de l'usage fréquent des boucles et des fonctions. : La présence de branchements atténue la localité temporelle des instruction, sauf que la majorité des branchements sautent à un endroit très proche, seuls les appels de fonction brisent la localité spatiale. La conséquence est qu'il arrive que certains CPU aient un cache L1 d'instruction plus gros que celui pour les données. On parle alors de '''cache L1 asymétriques'''. Un exemple est celui des processeurs AMD de microarchitecture Zen, dont le cache d'instruction était deux fois plus gros que le cache de données. Leur cache d'instruction faisait 64 kibioctets, contre seulement 32 pour le cache de données. D'ailleurs, il existe des processeurs assez extrêmes qui se contentent d'un cache d'instruction unique, sans cache de données. C'est le cas sur les processeurs vectoriels ou les GPU que nous verrons dans les chapitres de fin de ce wikilivres. De tels processeurs sont spécialisés dans la manipulation de tableaux de données, traitement qui a une faible localité temporelle. En conséquence, utiliser un cache de données n'est pas vraiment utile, voire peu être contreproductif, alors qu'un cache d’instruction fonctionne parfaitement. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Compléments sur les mémoires de masse | prevText=Compléments sur les mémoires de masse | next=Le préchargement | nextText=Le préchargement }} </noinclude> fwrrwwp1nspqe5yiid4tut72jj19gyc 765118 765117 2026-04-26T15:21:30Z Mewtow 31375 /* L'exemple des processeurs 386 et du contrôleur de cache 82385 */ 765118 wikitext text/x-wiki Le cache est une mémoire intercalée entre la mémoire et un processeur, plus rarement à l'intérieur d'un périphérique. Il est souvent fabriquée avec de la mémoire SRAM, parfois avec de l'eDRAM. Sans lui, on se croirait à l'âge de pierre tellement nos PC seraient lents ! En effet, la mémoire est très lente comparée au processeur. Le temps mis pour accéder à la mémoire est du temps durant lequel le processeur n'exécute pas d'instruction (sauf cas particuliers impliquant un pipeline). Pour diminuer ce temps d'attente, il a été décidé d'intercaler une mémoire petite mais rapide, entre le processeur et la mémoire. Ainsi, le processeur accède à un cache très rapide plutôt qu'à une RAM beaucoup plus lente. ==L'accès au cache== Le cache contient une copie de certaines données présentes en RAM. La copie présente dans le cache est accessible bien plus rapidement que celle en RAM, vu que le cache est plus rapide. Mais seule une petite partie de ces données sont copiées dans le cache, les autres données devant être lues ou écrites dans la RAM. Toujours est-il que le cache contient une copie des dernières données accédées par le processeur. Une donnée est copiée dans la mémoire cache quand elle est lue ou écrite par le processeur. Le processeur conserve une copie de la donnée dans le cache après son premier accès. Les lectures/écritures suivantes se feront alors directement dans le cache. Évidemment, au fur et à mesure des accès, certaines données anciennes sont éliminées du cache pour faire de la place aux nouveaux entrants, comme nous le verrons plus tard. [[File:Principe d'une mémoire cache.gif|centre|vignette|upright=2|Principe d'une mémoire cache.]] La mémoire cache est invisible pour le programmeur, qui ne peut pas déceler celles-ci dans l'assembleur. Les accès mémoire se font de la même manière avec ou sans le cache. La raison à cela est que le cache intercepte les accès mémoire et y répond s'il en a la capacité. Par exemple, si le cache intercepte une lecture à une adresse et que le contenu de cette adresse est dans le cache, le cache va outrepasser la mémoire RAM et la donnée sera envoyée par le cache au lieu d'être lue en RAM. par contre, si un accès se fait à une adresse pour laquelle le cache n'a pas la donnée, alors l'accès mémoire sera effectué par la RAM de la même manière que si le cache n'était pas là. [[File:Accès au cache.png|centre|vignette|upright=2|Accès au cache]] ===Les succès et défauts de caches=== Tout accès mémoire est intercepté par le cache, qui vérifie si la donnée demandée est présente ou non dans le cache. Si la donnée voulue est présente dans le cache, on a un '''succès de cache''' (''cache hit'') et on accède à la donnée depuis le cache. Sinon, c'est un '''défaut de cache''' (''cache miss'') et on est obligé d’accéder à la RAM. Les défauts de cache peuvent avoir plusieurs origines. Tout ce qu'il faut savoir est que lorsque le processeur accède à une donnée ou une instruction pour la première fois, il la place dans la mémoire cache car elle a de bonnes chances d'être réutilisée prochainement. La raison à cela est qu'un programme a tendance à réutiliser les instructions et données qui ont été accédées dans le passé : c'est le ''principe de localité temporelle''. Bien évidement, cela dépend du programme, de la façon dont celui-ci est programmé et accède à ses données et du traitement qu'il fait, mais c'est souvent vrai en général. La première cause des défauts de cache est liée à la taille du cache. À force de charger des données/instructions dans le cache, le cache fini par être trop petit pour conserver les anciennes données. Le cache doit bien finir par faire de la place en supprimant les anciennes données, qui ont peu de chances d'être réutilisées. Ces anciennes données éliminées du cache peuvent cependant être accédées plus tard. Tout prochain accès à cette donnée mènera à un cache miss. C'est ce qu'on appelle un ''Capacity Cache Miss'', ou encore '''défaut de capacité'''. Les seules solutions pour éviter cela consistent à augmenter la taille du cache ou à optimiser le programme exécuté (voir plus bas). Une autre raison pour un défaut est donc la suivante. Lorsqu'on exécute à une instruction ou qu'on accède à donnée pour la première fois, celle-ci n'a pas encore été chargée dans le cache. Le défaut de cache est inévitable : ce genre de cache miss s'appelle un ''Cold Miss'', ou encore un '''défaut à froid'''. De tels défauts sont presque impossibles à éliminer, sauf à utiliser des techniques de préchargement qui chargent à l'avance des données potentiellement utiles. Ces méthodes de préchargement se basent sur le principe de localité spatiale, à savoir le fait que les programmes ont tendance à accéder à des données proches en mémoire. Pour donner un exemple, les instructions d'un programme sont placées en mémoire dans l’ordre dans lequel on les exécute : la prochaine instruction à exécuter est souvent placée juste après l'instruction en cours (sauf avec les branchements). Quand on accède à une donnée ou une instruction, le cache peut précharger les données adjacentes pour en profiter. Nous parlerons de ces techniques de préchargement dans un chapitre dédié, vers la fin du cours. ===Le fonctionnement du cache, vu du processeur=== Vu du processeur, le cache prend en entrée toutes les informations nécessaires pour effectuer un accès mémoire : des signaux de commande, une adresse et la donnée à écrire si besoin. Tout cela est passé en entrée du cache, celui-ci répondant aux accès mémoire via divers bits de contrôles, que le processeur peut lire à souhait. Le cache fournit aussi la donnée à lire, pour les lectures, sur une sortie, connectée directement au bus mémoire/processeur. Globalement, le cache a une capacité limitée, mais il prend en entrée des adresses complètes. Par exemple, sur un processeur 64 bits, le cache prend en entrée des adresses de 64 bits (sauf si optimisations), même si le cache en question ne fait que quelques mébioctets. Les caches sont souvent des mémoires multiports, surtout sur les processeurs récents. Les caches simple port sont rares, mêmes s'ils existent et ont existé par le passé. les caches double port sont eux plus fréquents, et ont généralement un port d'écriture séparé du port de lecture. Mais les caches récents ont plusieurs ports de lecture/écriture et sont capables de gérer plusieurs accès mémoire simultanés. Les données présentes dans le cache sont (pré)chargées depuis la mémoire, ce qui fait que toute donnée dans le cache est la copie d'une donnée en mémoire RAM. Le cache doit faire la correspondance entre une donnée du cache et l'adresse mémoire correspondante. Du point de vue du fonctionnement, on peut voir le cache comme une sorte de table de correspondance, qui mémorise des données, chacune étant associée à son adresse mémoire. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Cela vaut du point de vue du processeur, le fonctionnement interne du cache étant quelque peu différent selon le cache. Il existe des caches dont le fonctionnement interne est bien celui d'une table de correspondance matérielle, d'autres qui sont beaucoup plus optimisés. [[File:Fonctionnement d'une mémoire associative à correspondance.png|centre|vignette|upright=2|Fonctionnement simplifié d'une mémoire cache : les adresses sont dans la colonne de gauche, les données sont dans la colonne de droite. On voit qu'on envoie l'adresse au cache, que celui-ci répond en renvoyant la donnée associée.]] ==La performance des mémoires caches== L'analyse de la performance des mémoires caches est plus riche pour celle des autres mémoires. Sa performance dépend de beaucoup de paramètres, mais on peut cependant citer les principaux. Les deux premiers sont tout bonnement sa latence et son débit, comme pour n'importe quelle autre mémoire. La latence est plus importante que son débit, car le processeur est généralement plus rapide que le cache et qu'il n'aime pas attendre. Mais le critère le plus important pour un cache est sa capacité à empêcher des accès mémoire, son efficacité. Plus les accès mémoire sont servis par le cache au lieu de la RAM, meilleures seront les performances. Pour résumer, la performance d'un cache est surtout caractérisée par deux métriques : le taux de défaut, qui correspond à l’efficacité du cache, et la latence du cache. ===Le taux de succès/défaut=== Le '''taux de succès''' (hit ratio) est un premier indicateur des performances du cache, mais un indicateur assez imparfait. C'est le pourcentage d'accès mémoire qui ne déclenchent pas de défaut de cache. Plus il est élevé, plus le processeur accède au cache à la place de la RAM et plus le cache est efficace. Certains chercheurs préfèrent utiliser le '''taux de défauts''', à savoir le pourcentage d'accès mémoire qui entraînent un défaut de cache. Plus il est bas, meilleures sont les performances. Le taux de défaut est relié au taux de succès par l'équation <math>T_\text{succes} = 1 - T_\text{defaut}</math>. Par définition, il est égal à : : <math>\text{Taux de défauts de cache} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d’accès mémoires}}</math> Plutôt que de comparer le nombre de défauts/succès de cache au nombre d'accès mémoire, il est aussi possible de diviser le nombre de défauts par le nombre total d'instructions. On obtient alors le '''taux de défauts/succès par instruction''', une autre métrique utile. Par définition, elle est égale à : : <math>\text{Taux de défauts par instruction} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d'instructions}} = \text{Taux de défauts de cache} \times \frac{\text{Nombre d’accès mémoires}}{\text{Nombre d'instructions}}</math> Si certains défauts de cache sont inévitables quel que soit le cache, comme les défauts à froids, mentionnés plus haut, d'autres défauts peuvent être évités en augmentant la capacité du cache. C'est le cas des défauts de capacité qui sont causés par un accès à une donnée qui a été éliminée du cache faute de place. Plus le cache est gros, moins il a de chances d'être rempli, moins il doit rapatrier de données, plus son taux de succès augmente. Mais nous reviendrons sur le lien entre taille du cache et taux de défaut plus bas. Le taux de succès ne dépend pas que du cache, mais aussi de la conception des programmes exécutés. Une bonne utilisation du cache (ainsi que de la mémoire virtuelle) repose sur le programmeur qui doit prendre en compte les principes de localités dès la conception de ses programmes. Par exemple, un programmeur peut parfaitement tenir compte du cache au niveau de son algorithme : on peut citer l'existence des algorithmes ''cache oblivious'', qui sont conçus pour être optimaux quelle que soit la taille du cache. Le programmeur peut aussi choisir ses structures de données de manière à améliorer la localité. Par exemple, un tableau est une structure de donnée respectant le principe de localité spatiale, tandis qu'une liste chaînée ou un arbre n'en sont pas (bien qu'on puisse les implémenter de façon à limiter la casse). D'autres optimisations sont parfois possibles : par exemple, le sens de parcours d'un tableau multidimensionnel peut faire une grosse différence. Cela permet des gains très intéressants pouvant se mesurer avec des nombres à deux ou trois chiffres. Je vous recommande, si vous êtes programmeur, de vous renseigner le plus possible sur les optimisations de code ou algorithmiques qui concernent le cache : il vous suffira de chercher sur Google. Il y a une citation qui résume bien cela, prononcée par un certain Terje Mathisen. Si vous ne le connaissez pas, cet homme est un vieux programmeur (du temps durant lequel on codait encore en assembleur), grand gourou de l’optimisation, qui a notamment travaillé sur le moteur de Quake 3 Arena. {{BlocCitation|Almost all programming can be viewed as an exercise in caching.|auteur=Terje Mathisen}} ===La latence moyenne d'un cache=== Le temps mis pour lire ou écrire une donnée varie en présence d'un cache. Certaines lectures/écritures vont atterrir directement dans le cache (succès) tandis que d'autres devront aller chercher leur contenu en mémoire RAM (défaut de cache). Dans tous les cas, qu'il y ait défaut ou non, le cache sera consulté et mettra un certain temps à répondre, égal au temps de latence du cache. Tous les accès mémoires auront donc une durée au moins égale au temps de latence du cache, qui sera notée <math>T_c</math>. En cas de succès, le cache aura effectué la lecture ou l'écriture, et aucune action supplémentaire n'est requise. Ce qui n'est pas le cas en cas de défaut : le processeur devra aller lire/écrire la donnée en RAM, ce qui prend un temps supplémentaire égal au temps de latence de la mémoire RAM. Un défaut ajoute donc un temps, une pénalité, à l'accès mémoire. Dans ce qui suivra, le temps d'accès à la RAM sera noté <math>T_m</math>. Fort de ces informations, nous pouvons calculer le temps de latence moyen d'un accès mémoire, qui est la somme du temps d'accès au cache (pour tous les accès mémoire), multiplié par le temps lié aux défauts. On a alors : : <math>T = T_c + \text{Taux de défaut} \times T_m</math> On voit que plus le taux de succès est élevé, plus le temps de latence moyen sera bas, et inversement. Ce qui explique l'influence du taux de succès sur les performances du cache, influence assez importante sur les processeurs actuels. De nos jours, le temps que passe le processeur dans les défauts de cache devient de plus en plus un problème au fil du temps, et gérer correctement le cache est une nécessité, particulièrement sur les processeurs multi-cœurs. Il faut dire que la différence de vitesse entre processeur et mémoire est tellement importante que les défauts de cache sont très lents : alors qu'un succès de cache va prendre entre 1 et 5 cycles d'horloge, un cache miss fera plus dans les 400-1000 cycles d'horloge. Tout ce temps sera du temps de perdu que le processeur aura du mal à mitiger. Autant dire que réduire les défauts de cache est beaucoup plus efficace que d'optimiser les calculs effectués par le processeur (erreur courante chez de nombreux programmeurs, notamment débutants). ===L'impact de la taille du cache sur le taux de défaut et la latence=== Il y a un lien entre taille du cache, taux de défaut, débit binaire et latence moyenne. Globalement, plus un cache est gros, plus il est lent. Simple application de la notion de hiérarchie mémoire vue il y a quelques chapitres. Les raisons à cela sont nombreuses, mais nous ne pouvons pas les aborder ici, car il faudrait que nous sachions comment fonctionne un cache et ce qu'il y a à l'intérieur, ce qui sera vu dans la suite du chapitre. Toujours est-il que la latence moyenne d'un cache assez gros est assez importante. De même, le débit binaire d'un cache diminue avec sa taille, mais dans une moindre mesure. Les petits caches ont donc un gros débit binaire et une faible latence, alors que c'est l'inverse pour les gros caches. Une grande capacité de cache améliore le taux de succès, mais cela se fait au détriment de son temps de latence et de son débit, ce qui fait qu'il y a un compromis assez difficile à trouver entre taille du cache, latence et débit. Il peut arriver qu'augmenter la taille du cache augmente son temps d'accès au point d’entraîner une baisse de performance. Par exemple, les processeurs Nehalem d'Intel ont vus leurs performances dans certains jeux vidéos baisser de 2 à 3 %, malgré de nombreuses améliorations architecturales, parce que la latence du cache L1 avait augmentée de 2 cycles d'horloge. Pour avoir une petite idée du compromis à faire, regardons la relation entre taille du cache et taux de défaut. Il existe une relation approximative entre ces deux variables, appelée la '''loi de puissance des défauts de cache'''. Elle donne le nombre total de défaut de cache en fonction de la taille du cache et de deux autres paramètres. Voici cette loi : : <math>\text{Taux de défauts de cache} \approx K \times \text{Taille du cache}^{- \alpha }</math>, avec <math>K</math> et <math>\alpha</math> deux coefficients qui dépendent du programme exécuté. Le coefficient <math>\alpha</math> est généralement compris entre 0.3 et 0.7, guère plus, et varie suivant le programme exécuté. Précisons que cette loi ne marche que si le cache est assez petit par rapport aux données à utiliser. Pour un cache assez gros et des données très petites, la relation précédente est mise en défaut. Pour s'en rendre compte, il suffit d'étudier le cas extrême où toutes les données nécessaires tiennent dans le cache. Dans ce cas, il n'y a qu'un nombre fixe de défauts de cache : autant qu'il faut charger de données dans le cache. Le nombre de défauts de cache observé dans cette situation n'est autre que le coefficient <math>K</math> de la situation précédente, mais il n'y a aucune dépendance entre taux de défaut et taille du cache. L'origine de cette relation s'explique quand on regarde combien de fois chaque donnée est réutilisée lors de l’exécution d'un programme. La plupart des données finissent par être ré-accédées à un moment ou un autre et il se passe un certain temps entre deux accès à une même donnée. Sur la plupart des programmes, les observations montrent que beaucoup de réutilisations de données se font après un temps très court et qu'inversement, peu de ré-accès se font après un temps inter-accès long. Si on compte le nombre de réutilisation qui ont un temps inter-accès bien précis, on retrouve une loi de puissance identique à celle vue précédemment : : <math>\text{Nombre de réaccès avec un temps inter-accès égal à t} \approx K \times t^{- \beta}</math>, avec t le temps moyen entre deux réutilisations. Le coefficient <math>\beta</math> est ici compris entre 1.7 et 1.3. De manière générale, les coefficients <math>\alpha</math> et <math>\beta</math> sont reliés par la relation <math>\alpha = 1 - \beta</math>, ce qui montre qu'il y a un lien entre les deux relations. Précisons cependant que la loi de puissance précédente ne vaut pas pour tous les programmes informatiques, mais seulement pour la plupart d’entre eux. Il n'est pas rare de trouver quelques programmes pour lesquels les accès aux données sont relativement prédictibles et où une bonne optimisation du code fait que la loi de puissance précédente n'est pas valide. La loi de puissance des défauts de cache peut se démontrer à partir de la relation précédente, sous certaines hypothèses. Si un suppose que le cache est assez petit par rapport aux données, alors les deux relations sont équivalentes. L'idée qui se cache derrière la démonstration est que si le temps entre deux accès à une donnée est trop long, alors la donnée accédée aura plus de chance d'être rapatriée en RAM, ce qui cause un défaut de cache. La chance de rapatriement dépend de la taille du cache, un cache plus gros peut conserver plus de données et a donc un temps avant rapatriement plus long. ==Les lignes de cache et leurs tags== Du point de vue du processeur, les lectures et écritures se font mot mémoire par mot mémoire. Un processeur avec des entiers de 64 bits recoit des données de 64 bits de la part du cache, et y écrit des mots de 64 bits. Mais quand on regarde comment sont stockées les données à l'intérieur du cache, les choses sont différentes. ===Les lignes de cache=== Les données sont mémorisées dans le cache par blocs de plusieurs bytes, d'environ 64 à 256 octets chacun, qui portent le nom de '''lignes de cache'''. Les lignes de cache sont l'unité de stockage que l'on trouve à l'intérieur du cache, mais elles servent aussi d'unité de transaction avec la mémoire RAM. Sur les caches actuels, on transfère les données entre le cache et la RAM ligne de cache par ligne de cache, dans la limite de la taille du bus mémoire. Mais d'autres caches plus anciens permettaient de faire des transferts plus fins. C’est-à-dire qu'on pouvait mettre à jour quelques octets dans une ligne de cache sans avoir à la recopier intégralement depuis ou dans la mémoire RAM. En théorie, on pourrait imaginer des caches où les données sont stockées différemment, où l'unité serait le mot mémoire, par exemple. Par exemple, sur un processeur 64 bits, on aurait une ligne de cache de 64 bits. Cela aurait l'avantage de la simplicité : les transferts entre le processeur et la mémoire serait de même taille, l'intérieur du cache ressemblerait à son interface montrée au processeur. Mais cela aurait quelques défauts qui sont compensés par l'organisation en lignes de cache de grande taille. Le premier avantage des lignes de cache est lié à la localité spatiale, la tendance qu'on les programmes à accéder à des données proches les unes des autres. Des accès mémoires consécutifs ont tendance à se faire à des adresses proches, qui ont de bonnes chances d'être dans la même ligne de cache. Et des accès consécutifs à une même ligne de cache sont plus rapides que des accès à deux lignes distinctes. Une autre raison est tout simplement que cela simplifie considérablement la circuiterie du cache. Pour une capacité identique, il vaut mieux avoir peu de lignes de cache assez grosses, que beaucoup de petites lignes de cache. La raison est que les circuits du cache, comme le décodeur, l'encodeur et autres, ont moins de sorties et sont donc plus simples. ===L'alignement des lignes de cache=== Les lignes de cache sont des blocs de plusieurs dizaines à centaines de bytes, dont la taille est presque toujours une puissance de deux. De plus, les lignes de cache sont alignées en mémoire. Nous avions déjà abordé la notion d'alignement mémoire dans un chapitre précédent, mais le concept d'alignement des lignes de cache est quelque peu différent. Quand nous avions parlé d'alignement auparavant, il s'agissait de l'alignement des données manipulées par le processeur, qui faisait partie du jeu d'instruction du processeur. Ici, nous parlons d'un alignement totalement différent, invisible pour le programmeur, sans lien avec le jeu d’instruction. Voyons de quoi il retourne. Concrètement, cela veut dire que du point de vue du cache, la RAM est découpée en blocs qui font la même taille qu'une ligne de cache, aux positions prédéterminées, sans recouvrement entre les blocs. Par exemple, pour un cache dont les lignes de cache font 256 octets, le premier bloc est à l'adresse 0, le second est 256 octets plus loin, c'est à dire à l'adresse 256, le troisième à l'adresse 512, la quatrième à l'adresse 768, etc. Une ligne de cache de 256 octets contiendra une donnée provenant d'un bloc de RAM de 256 octets, dont l'adresse est systématiquement un multiple de 256. Il n'est pas possible qu'une ligne de cache contienne un bloc de 256 octets dont l'adresse du premier octet serait l'adresse 64, ou l'adresse 32, par exemple. En clair, les adresses de ces blocs sont des multiples de la taille de la ligne de cache, de la taille des blocs. Cela rappelle les contraintes d'alignement vues dans le chapitre "Le modèle mémoire : alignement et boutisme", mais appliquées aux lignes de cache. L'alignement des lignes de cache a des conséquences pratiques pour la conception des caches. Notons qu'il est en théorie possible d'avoir des caches dont les lignes de cache ne sont pas alignées, mais cela poserait des problèmes majeurs. Il serait en effet possible qu'une donnée soit présente dans deux lignes de cache à la fois. Par exemple, prenons le cas où une ligne de cache de 256 commence à l'adresse 64 et une autre ligne de cache commence à l'adresse 0. L'adresse 128 serait dans les deux lignes de cache ! Et cela poserait des problèmes lors des lectures, mais encore plus lors des écritures. C'est pour éviter ce genre de problèmes que les lignes de cache sont alignées avec la mémoire RAM dans tous les caches existants. L'alignement des lignes de cache est une chose que les programmeurs doivent parfois prendre en compte quand ils écrivent du code ultra-optimisé, destiné à des programmes demandant des performances extrêmes. Il arrive que les contraintes d'alignement posent des problèmes. Nous avions vu dans le chapitre sur le boutisme et l'alignement qu'il valait mieux gérer l'alignement des variables des structures de données, pour éviter les accès non-alignés avec le bus mémoire. La même chose est possible, mais pour l'alignement avec des lignes de cache. Typiquement, l'idéal est que, pour une structure de donnée, on puisse en mettre un nombre entier dans une ligne de cache. Ou alors, si la structure est vraiment grande, que celle-ci occupe un nombre entier de lignes de cache. Si ce n'est pas le cas, il y a un risque d'accès non-alignés, c'est à dire qu'une structure se retrouve à cheval sur deux lignes de cache, avec les défauts que cela implique. ===Le tag d'une ligne de cache=== Plus haut, nous avions dit que le cache mémorise, pour chaque ligne de cache, l'adresse RAM associée. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Mais du fait de l'organisation du cache en lignes de cache de grande taille, qui sont de plus alignées en mémoire, il faut nuancer cette affirmation. Le cache ne mémorise pas la totalité de l'adresse, ce qui serait inutile. L'alignement des lignes de cache en RAM fait que les bits de poids faible de l'adresse ne sont pas à prendre en compte pour l'association adresse-ligne de cache. Dans ces conditions, on mémorise seulement la partie utile de l'adresse mémoire correspondante, qui forme ce qu'on appelle le '''tag'''. Le reste de l'adresse indique quelle est la position de la donnée dans la ligne de cache. Par exemple, prenons le cas où le processeur gère des nombres entiers de 64 bits (8 octets) et des lignes de cache de 128 octets : chaque ligne de cache contient donc 16 entiers. Si le processeur veut lire ou écrire un entier bien précis, il doit préciser sa place dans la ligne de cache. Et ce sont les bits de l'adresse mémoire non-inclus dans le cache qui permettent de faire ça. En clair, une adresse mémoire à lire/écrire est interprété par le cache comme la concaténation d'un tag et de la position de la donnée dans la ligne de cache correspondante. [[File:Adressage d'un cache totalement associatif.png|centre|vignette|upright=2|Adressage d'un cache totalement associatif]] Le cache est donc une grande table de correspondance entre tags et lignes de cache. Lors d'un accès mémoire, le cache extrait le tag de l'adresse à lire ou écrire, et le compare avec les tags de chaque ligne de cache. Si une ligne contient ce tag, alors c'est que cette ligne correspond à l'adresse, et c'est un défaut de cache sinon. Lors d'un succès de cache, la ligne de cache est lue depuis le cache et envoyée à un multiplexeur qui sélectionne la donnée à lire dans la ligne de cache. Le fonctionnement est similaire pour une écriture : la donnée à écrire passe dans un démultiplexeur, qui envoie la donnée au bon endroit dans la ligne de cache sélectionnée. [[File:Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.png|centre|vignette|upright=2|Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.]] ===Le contenu d'une ligne de cache=== Dans ce qui va suivre, nous allons considérer que chaque ligne de cache mémorise son tag, les données de la ligne de cache proprement dit, et quelques bits de contrôle annexes qui varient suivant le cache considéré. [[File:Tag d'une ligne de cache.png|centre|vignette|upright=2|Tag d'une ligne de cache.]] Les caches modernes incluent de nombreux bits de contrôle, mais deux d'entre eux sont communs à presque tous les caches modernes : le bit ''Dirty'' et le bit ''Valid''. Le '''bit ''Valid''''' indique si la ligne de cache contient des données valides ou non. Si le bit ''Valid'' est à 0, la ligne de cache est en état valide, à savoir qu'elle contient des données et n'est pas vide. Par contre, si ce bit est à 1, la ligne de cache est invalide et son contenu ne peut pas être lu ou écrit. L'utilité de ce bit est qu'il permet d'effacer une ligne de cache très rapidement : il suffit de mettre ce bit à 0. Il existe des situations où le cache doit être effacé, on dit alors qu'il est invalidé. Une section de ce chapitre sera dédié à l'invalidation du cache. Le '''bit ''Dirty''''' indique qu'une ligne de cache a été modifiée. Par modifiée, on veut dire que le processeur a écrit dedans, qu'il a modifié la ligne de cache. Mais attention : si la donnée a été modifiée dans le cache, la modification n'est pas forcément propagée en mémoire RAM. Le bit ''dirty'' indique si c'est le cas, si l'écriture a été propagée en mémoire RAM. Il précise que la ligne de cache contient des données modifiées, alors que la RAM a des données initiales non-modifiées. Une ligne de cache avec un bit ''dirty'' à 1 est dite ''dirty'', par métonymie. Nous verrons cela en détail dans la section sur les caches ''write-back'' et ''write-through''. Les caches modernes ajoutent des '''bits de détection/correction d'erreur''' dans les bits de contrôle. Pour rappel, les codes de détection/correction d'erreur permettent de se prémunir contre des erreurs matérielles, qui corrompent les données stockées dans une mémoire, ici une mémoire cache. Ils ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Nous reviendrons dessus dans une section ultérieur de ce chapitre. Sur certains caches assez anciens, on pouvait transférer les lignes de caches morceaux par morceaux. Ces caches avaient des lignes de cache divisées en sous-secteurs, ces sous-secteurs étant des morceaux de ligne de cache qu'on pouvait charger indépendamment les uns des autres (mais qui sont consécutifs en RAM). Chaque secteur avait ses propres bits de contrôle, mais le tag était commun à tous les secteurs. [[File:Cache à secteurs.png|centre|vignette|upright=2.5|Cache à secteurs.]] : Dans ce qui va suivre, le terme "ligne de cache" désignera soit un bloc de données copiées depuis la RAM d'une taille de 64/128/256/... octets, soit la concaténation de ces données avec le tag et des bits de contrôle. Les deux définitions ne sont pas équivalentes, mais l'usage a entériné cet abus de langage. Et il faut avouer que cela rend les explications du chapitre plus simples. ==Les instructions de contrôle du cache== Plus haut, nous avions dit que le cache est totalement transparent du point de vue du programmeur. Le cache contient des copies de données en RAM, le programmeur n'a rien à faire pour utiliser le cache correctement. Mais la réalité est que pour des raisons diverses, des processeurs incorporent des '''instructions de contrôle du cache'''. Il s'agit d’instructions qui agissent sur le contenu du cache. Elles existent pour des raisons diverses qu'on détaillera plus bas, mais il s'agit globalement d'une question de performances ou de nécessité pour le système d'exploitation. ===Les instructions de préchargement=== La première instruction de contrôle du cache est une '''instruction de préchargement''', qui demande à charger un bloc de données dans le cache. Elle prend en opérande une adresse mémoire, et le contenu de cette adresse est chargé dans une ligne de cache. Bien sûr, des contraintes d'alignement sont à prendre en compte : on charge un bloc de la même taille qu'une ligne de cache, aligné en mémoire sur la taille du bloc, qui contient l'adresse. L'instruction de préchargement n'est utile que si l'instruction est exécutée bien avant que la donnée ne soit utilisée/lue/écrite. Cela permet de charger une donnée dans le cache à l'avance, d'où le nom de préchargement donné à cette technique. Mais les processeurs modernes gérent des techniques de préchargement automatique, qui ne requièrent pas d'instructions de préchargement. Le préchargement automatique et les instructions de préchargement sont deux solutions complémentaires, mais qui peuvent se marcher sur les pieds. Nous en reparlerons dans le prochain chapitre, qui sera dédié au préchargement automatique. Il faut noter que les instructions de préchargement peuvent être ignorées par le processeur. Sous certaines conditions, le processeur peut décider que l'instruction de préchargement ne sera pas exécutée. Par exemple, il ne va pas précharger une donnée déjà présente dans le cache. Ou encore, si le bus mémoire est occupé, il ne va pas exécuter le préchargement, par manque de ressources matérielles. ===Les instructions d'invalidation et de ''flush''=== Les instructions ''flush'' regroupent deux types d'instructions qui sont souvent utilisées en même temps. Il s'agit des instructions d'invalidation et de nettoyage (''clean''). Les deux termes proviennent de la terminologie ARM, il n'y a pas de terminologie standardisé pour les noms de ces instructions. Dans les grandes lignes, elles permettent de vider le cache, à savoir de rapatrier son contenu en RAM et de réinitialiser le cache à zéro. Elles sont utilisées par le système d'exploitation lors des commutations de contexte, à savoir quand on passe d'un programme à un autre. Elles sont aussi utilisées lors des appels systèmes et routines d'interruption/exception. L'idée est de vider le cache avant d'exécuter un nouveau programme ou une nouvelle routine. Le nouveau programme aura accès à un cache tout propre, les données de l'ancien programme auront été retirée du cache. Les '''instructions ''clean''''' recopient le contenu de la ligne de cache en RAM. Elles forcent la recopie immédiatement de la ligne de cache en mémoire RAM. Pour faire leur travail, elle vérifient si la ligne de cache a été modifiée, avant de la recopier en RAM. Et pour cela, ils vérifient le bit de contrôle ''dirty'', qui est mis à 1 après une première écriture. Si ce bit est à 0, alors pas besoin de recopier la ligne de cache : elle n'a pas été modifiée, la RAM a déjà la bonne copie. Mais s'il est à 1, le cache et la RAM n'ont pas le même contenu, la recopie s'exécute. Les '''instructions d'invalidation''' permettent d'invalider une ligne de cache, à savoir d'effacer son contenu. Nous verrons à quoi servent ces instructions dans la section sur les changement de processus. Invalider une ligne de cache est une opération optimisée : le cache n'est en réalité pas réellement effacé. À la place, le bit ''Valid'' de chaque ligne de cache est juste mis à 0. Il faut noter que l'invalidation efface les lignes de cache sans se préoccuper de leur contenu. Elle se moque qu'une ligne de cache contienne une donnée modifiée, ''dirty'' ou quoique ce soit : la ligne de cache est effacée, point. Il est possible d'invalider une ligne de cache en fournissant une adresse mémoire, mais il est aussi possible d'invalider le cache tout entier. Le choix entre les deux dépend du mode d'adressage de l'instruction d'invalidation. Parfois, il existe une instruction séparée pour invalider tout le cache, et une autre pour invalider une ligne de cache bien précise. Des instructions séparées sont parfois disponibles pour invalider les caches de données et d'instructions, parfois aussi la TLB (un cache qu'on verra dans quelques chapitres). Il est possible de n'invalider que le cache L1, voire le cache L2. Il faut noter que l'invalidation efface tout le cache, mais ne se préoccupe pas de vérifier si les données ont été modifiées dans le cache. Pour certains caches, comme le cache d'instruction, ce n'est pas un problème, vu qu'il est en "lecture seule". Mais pour les caches de données, les données modifiées sont perdues en cas d'invalidation. Heureusement, il existe des instructions d'invalidation qui fusionnent une instruction ''clean'' et une instruction d'invalidation. Il s'agit d''''instructions d'invalidation spéciales'''. ===Les instructions d'optimisation : instructions non-temporelles et écritures optimisées=== Les '''instructions mémoire non-temporelles''' contournent complètement le cache. Par exemple, une lecture peut lire une donnée, mais celle-ci ne sera pas chargée dans le cache, elle passe directement de la RAM vers les registres. Une section entière de ce chapitre sera dédiée au contournement du cache, à savoir aux situations où les accès mémoire doivent passer directement du processeur à la RAM sans passer par le cache. D'autres instructions assez rares incorporent des indications pour le cache. Par exemple, l'instruction ''load last'' des processeurs POWER PC implique que la donnée ne sera utilisée qu'une seule fois. Elle est donc chargée dans le cache, mais la ligne de cache est configurée de manière à être remplacée très rapidement, typiquement avec une valeur de LRU/LFU adéquate. La donnée est bien chargée dans le cache, au cas où elle doive être relue suite à une mauvaise prédiction de branchement ou autre, chose qu'une lecture non-temporelle (qui contourne le cache) ne fait pas. Des indications de ce type sont appelées des '''''cache hint'''''. L''''instruction ''flush''''' permet de préciser qu'une ligne de cache contient une donnée inutile, qui ne sera pas réutilisée par le programme. Pas besoin de la conserver dans le cache, elle peut laisser sa place à des données plus utiles. Or, sans indication, les algorithmes de remplacement d'une ligne de cache risquent de conserver cette donnée trop longtemps, ce qui entraine une certaine pollution du cache par des données inutiles. Une autre instruction est elle beaucoup plus importante : celle de '''pré-allocation sur écriture'''. Elle sert dans le cas où une ligne de cache est complétement écrite. Par exemple, imaginons qu'on veuille écrire dans une portion de mémoire. Si celle-ci n'est pas dans le cache, le processeur va charger une ligne de cache complète depuis la RAM, écrire dans la ligne de cache, puis recopier la ligne de cache modifiée en mémoire RAM. Une écriture en RAM demande donc de faire une lecture et une écriture. Mais les instructions de pré-allocation sur écriture permettent de prévenir qu'une ligne de cache sera intégralement écrite, et qu'il n'y a donc pas besoin de lire celle-ci depuis la RAM. Notons que l'instruction d'écriture qui suit n'est pas une écriture non-temporelle, vu que les données sont écrites dans la ligne de cache, qui est ensuite envoyée en mémoire RAM dès que nécessaire. De plus, les données écrites peuvent ensuite être relue depuis le cache si nécessaire. Enfin, certains processeurs MIPS incorporent une instruction pour modifier le tag d'une ligne de cache. Elles servent à optimiser les copies mémoire, à savoir quand on copie un bloc de données d'un endroit à un autre. L'idée est de charger le bloc de données dans le cache avec une instruction LOAD/PREFETCH, de modifier le tag pour qu'il pointe vers l'adresse à écrire, et de laisser faire le cache pour que l'écriture se fasse en RAM. Mais les contraintes pour utiliser cette instruction sont assez drastiques : les données doivent être alignées sur la taille d'une ligne de cache, le bloc de départ et d'arrivée (l'original versus la copie) ne doivent pas se recouvrir, etc. ==L'associativité des caches et leur adressage implicite== Lorsqu'on souhaite accéder au cache, il faut trouver quelle est la ligne de cache dont le tag correspond à l'adresse demandée. On peut classifier les caches selon leur stratégie de recherche de la ligne correspondante en trois types de caches : totalement associatifs, directement adressés (''direct mapped'') et associatifs par voie. ===Les caches totalement associatifs=== Avec les caches totalement associatifs, toute donnée chargée depuis la mémoire peut être placée dans n'importe quelle ligne de cache, sans aucune restriction. Ces caches ont un taux de succès très élevé, quand on les compare aux autres caches. [[File:Cache totalement associatif.png|centre|vignette|upright=2|Cache totalement associatif.]] Concevoir un cache totalement associatif peut se faire de deux grandes manières différentes. La première consiste tout simplement à combiner une mémoire associative avec une mémoire RAM, en ajoutant éventuellement quelques circuits annexes. La mémoire associative mémorise les tags, alors que la mémoire RAM mémorise les données de la ligne de cache, éventuellement avec quelques bits de contrôle. La ligne de cache est stockée à une adresse A dans la mémoire RAM et son tag est stocké à la même adresse, mais dans la mémoire CAM. Ce faisant, quand on envoie le tag à la mémoire CAM, elle renvoie l'adresse de la ligne de cache dans la mémoire RAM. Cette adresse est alors envoyée directement sur le bus d'adresse de la RAM, et la lecture est effectuée automatiquement. Il faut ajouter quelques circuits annexes pour garantir que les écritures se passent correctement dans les deux mémoires, mais rien de bien terrible. [[File:Cache fabriqué avec une mémoire associative et une RAM.png|centre|vignette|upright=3|Cache fabriqué avec une mémoire associative et une RAM]] Il est cependant possible d'optimiser un tel cache, en fusionnant la mémoire CAM et la mémoire RAM, afin d'éliminer des circuits redondants. Pour comprendre pourquoi, rappelons que les mémoires CAM sont composées d'un plan mémoire, d'un paquet de comparateurs et d'un encodeur. Quant à la mémoire RAM, elle est composée d'un décodeur connecté au plan mémoire. En mettant une CAM suivie d'une RAM, on a un encodeur dont l'entrée est envoyée à un décodeur. [[File:Cache totalement associatif naif.png|centre|vignette|upright=3|Cache totalement associatif naif]] Or, le décodeur réalise l'opération inverse de l'encodeur, ce qui fait que mettre les deux composants à la suite ne sert à rien. On peut donc retirer l'encodeur et le décodeur, et envoyer directement les résultats des comparateurs sur les entrées de commande du plan mémoire de la RAM. [[File:Cache totalement associatif optimisé.png|centre|vignette|upright=2|Cache totalement associatif optimisé]] Avec cette méthode, les circuits du cache ressemblent à ce qui illustré ci-dessous. Le tag est envoyé à chaque ligne de cache. Le tag envoyé est alors comparé avec le Tag contenu dans chaque ligne de cache, comme c'est le cas sur les mémoires associatives. Si une ligne de cache matche avec le tag envoyé en entrée, la ligne pour laquelle il y a eu une égalité est alors connectée sur les lignes de bit (''bitlines''). Cela est réalisé par un circuit commandé par le comparateur de la ligne de cache. Il ne reste plus qu'à sélectionner la portion de la ligne de cache qui nous intéresse, grâce à un paquet de multiplexeurs. Cela permet d'effectuer une lecture ou écriture, mais il faut aussi préciser si il y a eu un défaut de cache ou un succès. Un succès de cache a lieu quand au moins des comparaisons est positive, alors que c'est un défaut de cache sinon. En clair, détecter un succès de cache demande juste de connecter une porte OU à plusieurs entrées à tous les comparateurs. [[File:Organisation générale d'un cache totalement associatif.png|centre|vignette|upright=2|Organisation générale d'un cache totalement associatif.]] ===Les caches directement adressés=== Les caches directement adressés peuvent être vus comme un cache totalement associatif auquel on aurait ajouté des restrictions assez drastiques. Plus haut, on a vu qu'un cache totalement adressé est équivalent à la combinaison d'une CAM avec une RAM. La mémoire CAM prend en entrée un Tag et traduit celui-ci en une adresse qui commande la mémoire RAM interne au cache. Dans ce qui suit, l'adresse interne au cache sera appelé l''''indice''' pour éviter toute confusion. [[File:Cache hash table - 2.png|centre|vignette|upright=2|Fonctionnement interne du cache, expliquée sous forme abstraite, en utilisant la notion d'indice interne au cache.]] Les caches directement adressés cherchent à remplacer la mémoire CAM par un circuit combinatoire. Ce circuit traduit le Tag en indice, mais est beaucoup plus simple qu'une mémoire CAM. Mais qui dit circuit plus simple dit circuit plus limité. Un circuit combinatoire n'est pas aussi versatile que ce qui est permis avec une mémoire CAM. En conséquence, une restriction majeure apparait : toute adresse mémoire est associée dans une ligne de cache prédéfinie, toujours la même. L'association entre ligne de cache et adresse mémoire est faite par le circuit combinatoire, et ne peut pas changer. Les concepteurs de caches s'arrangent pour que des adresses consécutives en mémoire RAM occupent des lignes de cache consécutives, par souci de simplicité. Tout se passe comme suit la mémoire RAM était découpés en blocs de la même taille que le cache. La première adresse du bloc est associée à la première ligne de cache (celle d'indice 0), la seconde adresse est associée à la seconde adresse du_ bloc, et ainsi de suite. Le tout est illustré ci-dessous. [[File:Cache adressé directement.png|centre|vignette|upright=2|Cache adressé directement.]] Avec cette contrainte, le circuit de traduction de l'adresse en adresse mémoire pour la RAM interne au cache est drastiquement simplifié, et disparait même. Une partie de l'adresse mémoire sert à indiquer la position de la donnée dans le cache, le reste de l'adresse sert encode le tag et la position de la donnée dans le ligne de cache. [[File:Cache line.png|centre|vignette|upright=2|Adresse d'une ligne de cache sur un cache adressé directement.]] Un cache directement adressé est conçu avec une RAM, un comparateur, et un paquet de multiplexeurs. En général, la mémoire RAM stocke les lignes de caches complète. Il arrive que l'on utilise deux mémoires RAM : une pour les tags et une pour les données, mais cette technique augmente le nombre de circuits et de portes logiques nécessaires, ce qui réduit la capacité du cache. L'index à lire/écrire est envoyé sur l'entrée d'adresse de la RAM, la RAM réagit en mettant la ligne de cache sur sa sortie de donnée. Sur cette sortie, un comparateur compare le tag de la ligne de cache lue avec le tag de l'adresse à lire ou écrire. On saura alors si on doit faire face à un défaut de cache. Ensuite, un multiplexeur récupère la donnée à lire/écrire. [[File:Direct mapped cache - french.png|centre|vignette|upright=2|Cache directement adressé.]] L'accès à un cache directement adressé a l'avantage d'être très rapide vu qu'il suffit de vérifier une seule ligne de cache : celle prédéfinie. Mais ces caches ne sont cependant pas sans défauts. Vu que le cache est plus petit que la mémoire, certaines adresses mémoires se partagent la même ligne de cache. Si le processeur a besoin d’accéder fréquemment à ces adresses, chaque accès à une adresse supprimera l'autre du cache : tout accès à l'ancienne adresse se soldera par un défaut de cache. Ce genre de défauts de cache causés par le fait que deux adresses mémoires ne peuvent utiliser la même ligne de cache s'appelle un '''défaut par conflit''' (''conflict miss''). Les défauts par conflit n'existent pas sur les caches totalement associatifs. En conséquence, le taux de succès des caches directement adressés est assez faible comparé aux autres caches. [[File:Cache Block Basic Conflict.svg|centre|vignette|upright=1.5|Exemple de ''Conflict Miss''.]] ===Les caches associatifs par voie=== Les caches associatifs par voie sont un compromis entre les caches directement adressés et les caches totalement associatifs. Pour simplifier, ces caches sont composés de plusieurs caches directement adressés accessibles en parallèle, chaque cache/RAM étant appelé une '''voie'''. Avec ces caches, toute adresse mémoire en RAM est associée à une ligne de cache dans chaque voie. [[File:Cache associatif par voie.png|centre|vignette|upright=2|Cache associatif par voie.]] Le schéma ci-dessous compare un cache directement adressé et un cache associatif à deux voies. On voit que chaque adresse est associée à une ligne de cache bien précise avec un cache directement dressé, et à deux lignes de cache avec un cache associatif à deux voies. L'adresse sera associée à 4 lignes de cache sur un cache associatif à 4 voies, à 8 lignes pour un cache à 8 voies, etc. L'ensemble des lignes de cache associées à une adresse est appelé un '''ensemble'''. [[File:Cache Fill.svg|centre|vignette|upright=2|Comparaison entre un cache directement adressé et un cache associatif à deux voies.]] Sur ces caches, toute adresse est découpée en trois parties : un tag, un index, et un décalage, comme sur les caches directement adressés. Comme vous pouvez le voir, l'organisation est identique à celle d'un cache totalement associatif, à part que chaque ensemble tag-ligne de cache est remplacé par une mémoire RAM qui en contient plusieurs. [[File:Implémentation d'un cache associatif par voie.png|centre|vignette|upright=2|Implémentation d'un cache associatif par voie.]] Le risque de conflits d'accès au cache est donc réduit sur un cache associatif à plusieurs voies, et il est d'autant plus réduit que le cache a de voies. Par contre, leur conception interne fait qu'ils ont un temps d'accès légèrement élevé que les caches directement adressés. Les caches associatifs par voie ont donc un taux de succès et un temps d'accès intermédiaire, situé entre les caches directement adressés et totalement associatifs. Ils sont une sorte de compromis entre réduction des défaut par conflits d'accès au cache et temps d'accès, et complexité des circuits. ==Les optimisations des caches associatifs par voie== Les caches partiellement associatifs regroupent les caches associatifs par voie et directement adressés, ainsi que leurs variantes. En clair : tous les caches qui ne sont pas totalement associatifs. Ils peuvent être optimisés de nombreuses manières, que ce soit pour gagner en performance ou pour économiser de l’énergie. Dans cette section, nous allons voir quelles sont ces optimisations. ===Les caches pseudo-associatifs=== Les caches adressés par voie contiennent une mémoire SRAM par voie. En théorie, les voies sont accédées en parallèles, en même temps, afin de voir si l'on a un succès de cache ou un défaut. Les '''caches pseudo-associatifs''' sont identiques aux caches associatifs par voie, si ce n'est qu'ils vérifient chaque voie une par une. Ils ont été utilisés sur des processeurs commerciaux, un exemple étant l'IBM 370. Là encore, on perd en performance pour gagner en consommation d'énergie. Le temps d'accès dans le meilleur des cas est plus faible pour les caches pseudo-associatifs, mais le pire des cas teste tous les caches avant de tomber sur le bon. Les performances sont donc réduites. Mais la consommation énergétique est meilleure, vu qu'on ne vérifie pas forcément toutes les voies en parallèle. On teste la première voie, éventuellement la seconde, peut-être la troisième, etc. Mais dans le cas général, on ne teste qu'une partie des voies, pas toutes, ce qui donne un gain en termes d'énergie. L'implémentation de caches de ce genre demande que l'on parcoure les voies une par une, en commençant de la première jusqu'à la dernière. Pour cela, un simple compteur suffit. Suivant la valeur du compteur, la voie associée est activée puis accédée. Toute la complexité revient à ajouter un circuit qui prend la valeur du compteur, et active la voie associée, lance un accès mémoire dessus. Vu que les voies sont chacune des caches ''direct mapped'', il suffit pour cela de geler les entrées d'adresse, soit en les déconnectant, soit en utilisant du ''clock gating'' ou de l'évaluation gardée. Les détails d'implémentation, non-cités ici, varient selon le cache. ===La prédiction de voie=== Pour réduire le temps d'accès des caches pseudo-associatifs, certains chercheurs ont inventé la '''prédiction de voie''', qui consiste à faire des paris sur la prochaine voie accédée. L'idée est d'accéder à la voie qui contient la donnée voulue du premier coup, en lisant celle-ci en priorité. Dans son implémentation la plus simple, le cache reste un cache pseudo-associatif. Lors d'un accès au cache, les voies sont toutes parcoures une par une. Par contre, les voies ne sont donc pas parcourues de la première vers la dernière, mais dans un ordre différent. Cette technique permet de mettre en veille les voies sur lesquels le processeur n'a pas parié, ce qui permet de diminuer la consommation énergétique du processeur. C'est plus efficace que d'aller lire plusieurs données dans des voies différentes et de n'en garder qu'une. L'implémentation est assez simple : il suffit d'ajouter un circuit de prédiction de voie,relié au compteur de voie. Une amélioration de la technique fait fonctionner le cache comme un intermédiaire entre cache pseudo-associatif et associatif par voies. L'idée est de chercher la voie prédite en premier, puis de chercher dans toutes les voies en parallèle en cas de défaut de cache. Au lieu d'attendre que les comparaisons de tags donnent leur résultat, le processeur sélectionne automatiquement une voie et configure les multiplexeurs à l'avance. Si le processeur ne se trompe pas, le processeur accède à la donnée plus tôt que prévu. S'il se trompe, le processeur annule la lecture effectuée en avance et recommence en faisant un accès en parallèle aux autres voies. Le compromis entre performance et consommation d'énergie est alors différent. On économise de l'énergie par rapport à un cache associatif par voie, au prix d'une petite perte de performance (doublement des temps d'accès). Mais par rapport à un cache pseudo-associatif, l'économie d'énergie est bien moindre, au prix d'un gain en performance assez manifeste. Prédire quelle voie sera la bonne est assez simple. En vertu du principe de localité, les accès futurs ont des chances de tomber dans les voies les plus fréquemment utilisées ou dans celle plus récemment utilisée. Il suffit de retenir la voie la plus récemment accédée dans un registre, qui sera utilisée comme prédiction. Pour vérifier que la prédiction est correcte, il suffit de comparer le registre et le résultat obtenu après vérification des tags. Cependant, on peut complexifier l'implémentation pour prendre en compte l'adresse à lire/écrire, l'instruction à l'origine de l'accès mémoire ou tout autre paramètre utile. Par exemple, des instructions différentes ont tendance à aller chercher leurs données dans des ensembles différents et la voie à choisir n'est pas la même. Pour cela, il suffit d'utiliser un cache pour stocker la correspondance instruction - voie. Pour plus de simplicité, la mémoire cache des prédictions est parfois remplacée par une RAM, qui est adressée : * soit par le program counter de l'instruction à l'origine de l'accès (en réalité, seulement quelques bits de poids faible de l'adresse) ; * soit par l'adresse à accéder (là encore, quelques bits de poids faible) ; * soit (pour les modes d'adressage qui utilisent un registre de base et un décalage) par un XOR entre les bits de poids faible de l'adresse de base et le décalage ; * soit par autre chose. ===La mise en veille sélective des voies=== Les caches associatifs ont tendance à utiliser beaucoup d'énergie, même quand on n'y accède pas. Aussi, certains processeurs détectent quand le cache est peu utilisé et en profitent pour mettre en veille les voies inutilisées. Vous vous demandez certainement ce qui se passe quand une donnée à lire/écrire est dans une voie désactivée. La réponse est que le cache détecte cette situation, car elle déclenche un succès de cache. Les ''tags'' ne sont en effet pas désactivés, seules les données sont mises en veille. L'implémentation est plus simple sur les caches qui séparent les tags et les données dans deux RAM différentes. Cette optimisation marche surtout sur les gros caches, qui ont des chances d'avoir une portion significative d’inutilisée (pas assez de données pour les remplir), donc généralement les caches L3/L4. Par exemple, les processeurs d'Intel de microarchitecture Ivy Bridge disposent d'un cache de 8 mébioctets à 16 voies, qu'ils peuvent faire passer à 512 kibioctets si le besoin s'en fait sentir. Quand ces processeurs détectent une faible activité, ils mettent en veille 14 voies et n'en gardent que 2 d'actives. Évidemment, les 14 voies sont vidées avant d'être mises en veille, afin qu'une aucune donnée ne soit perdue. ===Les caches ''skew-associative''=== Vous aurez remarqué que dans une voie, les lignes sont accédées en adressage direct : les défauts par conflit sont possibles sur un cache associatif par voie. Pour éviter cela, certains chercheurs ont créé des '''caches ''skew associative''''' (ou associatifs à biais). Pour faire simple, les index des lignes de cache subissent un petit traitement avant d'être utilisés. Le traitement en question est différent suivant la voie de destination, histoire que deux adresses mémoires avec des index identiques donnent des index différents après traitement. Le traitement en question est souvent une permutation des bits de l'index, qui est différente suivant la voie prise, ou un simple XOR avec un nombre qui dépend de la voie. [[File:Implémentation d'un cache skew associative.jpg|centre|vignette|upright=2|Implémentation d'un cache skew associative.]] ==Les caches splittés (''phased caches'')== Dans cette section, nous allons voir les '''caches splittés''' (''phased caches''), qui sont une variante des caches ''direct-mapped'', dans lequel le cache est accédé en deux étapes consécutives. Il ne s'agit pas des caches pipelinés, que nous verrons dans le chapitre sur les processeurs pipélinés, mais laissons cela à plus tard. Il est possible d'appliquer la même méthode sur un cache associatif par voie, mais il y a des méthodes plus simples, qui permettent là aussi d’accéder au cache en plusieurs étapes consécutives. L'idée est de scinder le cache en deux : une mémoire pour les tags, une autre pour les données de la ligne de cache. Les bits de contrôle peuvent être mis dans l'une ou l'autre SRAM, mais ils sont souvent mis dans la RAM pour les tags. En faisant cela, quelques optimisations deviennent possibles, afin de réduire la consommation énergétique en contrepartie d'une perte de performance. La technique s'implémente différemment pour les caches totalement associatifs et partiellement associatifs. Les caches totalement associatifs splittés sont ceux formés en combinant un cache associatif avec une CAM et une RAM combinée. On envoie l'adresse à lire/écrire à la mémoire associative, elle répond en envoyant une adresse à la mémoire RAM. L'accès se fait donc en deux temps, avec l'adresse dans la RAM comme intermédiaire. Il est possible de séparer physiquement les deux étapes en insérant un registre entre la CAM et la RAM, ce qui permet aussi de pipeliner l'accès. Mais c'est rarement fait en pratique, car le cout en circuit d'une mémoire CAM est trop important. L'équivalent pour un cache totalement associatif optimisé, sans CAM et RAM séparée, est trop gourmande en interconnexions pour être implémentée. Les caches totalement associatifs splittés sont donc très rares, l'auteur ne connait aucun exemple de processeur avec un tel cache. Il existe une technique équivalente pour les caches ''direct-mapped'', mais elle demande une certaine modification du cache. Dans les caches ''direct-mapped'' non-splittés, on trouve une mémoire SRAM dont chaque mot mémoire contient une ligne de cache entière, tag inclus. Dans leurs versions splittés, la SRAM est séparée en deux : une pour les tags, une autre pour les données. Précisons qu'il s'agit bien de deux mémoires SRAM adressables. L'adresse à laquelle accéder est envoyée à la SRAM des tags, puis ensuite à la SRAM des données si besoin. L'idée est d’accéder aux tags pour déterminer s'il y a un succès de cache ou un défaut, et ensuite d'accéder aux données. On n’accède pas aux données en parallèle des tags. Faire cela est évidemment plus lent. En cas de défaut de cache, le temps d'accès est similaire : le tag ne correspond pas, on n'accède pas à la SRAM pour les données. Par contre, vu qu'on n'a pas activé la SRAM pour les données, on économise un peu d'énergie, ce qui réduit la consommation d'énergie. En cas de succès de cache, on accède à la SRAM pour les tags, puis à celle pour les données. Pas d'économie d'énergie à l'horizon, sans compter que le temps d'accès augmente : on accède au cache en deux étapes au lieu de faire les deux accès en parallèle. [[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]] Précisons cependant que ce design peut avoir deux avantages en termes de performance. Premièrement, le temps d'accès au cache est légèrement amélioré en cas de défaut de cache. En effet, la SRAM des tags est assez petite, idem pour celle des données. Leur temps d'accès est donc plus faible que pour une grosse SRAM contenant données et tags. Le gain en temps d'accès est donc un avantage, qui ne se manifeste surtout en cas de défaut de cache. Un autre avantage est que l'accès au cache se pipeline plus facilement, ce qui fait qu'on peut effectuer plusieurs accès simultanés au cache. Mais nous verrons cela dans quelques chapitres. ===L'exemple des processeurs 386 et du contrôleur de cache 82385=== Il est important de noter que la séparation entre tags et RAM peut être telle que les deux ne sont pas sur la même puce de silicium ! Voire que les deux sont séparés du processeur ! C'était le cas quand les mémoires caches ont été introduites sur les processeurs grand public, notamment sur les premiers processeurs Intel. La miniaturisation n'avait pas avancé au point où placer un cache dans le processeur était possible. Sur le processeur 386 d'Intel, le cache était un cache splitté, séparé du processeur. Concrètement, le processeur i386 était couplé à un contrôleur de cache Intel 82385 contenant les ''tags'' et les bits de contrôle, et une mémoire SRAM pour les lignes de cache. Le 82385 surveillait ce qui se passait sur le bus de données et répondait à la place de la RAM pour certaines lectures. Le schéma ci-dessous montre comment le cache s'intégrait avec le bus système. Pour le bus de commande, le cache servait d'intermédiaire : il recevait les commandes et et les filtrait suivant les succès ou défaut de cache. Les adresses étaient transmises directement au cache, mais aussi au bus système (avec un registre entre les deux). Le bus de donnée était lui connecté à la mémoire SRAM et au processeur, avec des MUX/DEMUX pour faire le choix de la source des lectures. Le cache était un cache ''write through''. [[File:Controleur de cache 82385 pour l'Intel 386.png|centre|vignette|upright=2.5|Contrôleur de cache 82385 pour l'Intel 386]] Il pouvait commander soit un cache ''direct mapped'', soit associatif à deux voies. La différence entre les deux est que le 82385 était relié à une mémoire SRAM avec un cache ''direct mapped'', deux SRAM pour deux voies. Pour avoir un cache associatif à deux voies, le 82385 devrait gérer deux signaux ''chip select'' pour activer chaque SRAM/voie suivant les besoins. Il avait précisément quatre signaux CS : deux par SRAM, un pour les lectures, un pour les écritures. Notons que les SRAM utilisées devaient être des SRAM avec un bus de données de 32 bits, des SRAM 16 ou 8 bits ne fonctionnaient pas. Le 82385 rajoutait 4 sorties, pour masquer chaque octet dans ces 32 bits. Le choix entre les deux était le fait d'une entrée : la mettre à 0 indiquait un cache ''direct mapped'', la mettre à 1 forçait un cache à deux voies. Il gérait aussi les accès mémoire non-cacheable, à savoir des accès mémoire qui ne doivent pas être pris en compte par le cache. Pour cela, il avait une entrée NCA (''Non Cacheable Access'') : le cache était "désactivé" quand cette entrée était à 1. C'est un peu une sorte de ''chip select'' pour le 82385, limitée aux accès mémoire. Cette entrée permettait de programmer des intervalles d'adresse auxquels ne pas répondre, en utilisant des circuits de décodage d'adresse adaptés. Il avait aussi une entrée X16, qui permettait d'identifier les accès soit à un composant 16 bits, soit à une entrée-sortie. De tels accès ne doivent pas être mis en cache, ce qui était garanti par cette entrée. Pour rappel, le processeur utilisait un espace d'adressage séparé pour les entrées-sorties, et utilisait donc un bit IO. Ce bit IO pouvait être utilisé pour déterminer le bit X16, qui inhibe l'action du cache. Cette surveillance du bus permettait de gérer une forme limitée de cohérence des caches par invalidation. Dès que le 82385 détectait une prise de contrôle du bus par autre chose que le processeur, il surveillait les adresses transmises sur le bus. En cas de succès de cache, la ligne de cache associée était invalidée. Au-delà de ca, le 82385 avait une entrée FLUSH, qui ordonnait une invalidation totale du cache. Si cette entrée est mise à 1, toutes les lignes de cache sont invalidées. Les ''tags'' sont marqués comme invalides, mais les lignes de cache elles-mêmes ne sont pas touchées. Le 82385 pouvait être intégré dans un système à deux processeurs, voire plus. Pour cela, chaque processeur avait son propre 82385 et sa SRAM rien qu'à lui. Il n'y avait pas de cache partagé entre les deux processeurs. Par contre, les deux caches étaient reliés au même bus système. Pour qu'ils ne se marchent pas sur les pieds, il y avait des circuits d'arbitrage pour gérer l'accès au bus. Un des deux 82385 était mis en mode maitre, l'autre était en mode esclave. Le 82385 maitre pouvait prendre le contrôle du bus, le 82385 esclave devait demander l'autorisation au premier pour accéder au bus système. ===L'exemple des processeurs Intel de microarchitecture ''Broadwell''=== Un autre exemple est celui du cache L4 des processeurs Broadwell et de quelques processeurs séparés. Ces processeurs ont une organisation en ''chiplet'' où le processeur incorpore plusieurs puces séparées : une puce pour le processeur proprement dit, une puce nommée ''Crystal Well'' pour le cache L4, et une puce IO pour la communication avec la RAM et la carte mère. Le processeur incorporait un cache L4 de 128 mébioctets, composé de mémoire eDRAM, qui était dispersé entre ''Crystal Well'' et les autres puces. Les données du cache L4 étaient dans ''Crystal Well'', alors que les Tags étaient soit dans le processeur lui-même, soit dans la puce IO ! La puce ''Crystal Well'' était une mémoire DRAM adressable tout ce qu'il y a de plus basique, avec cependant quelques optimisations notables. Par exemple, elle avait deux bus séparés pour l'écriture et la lecture. De plus, elle avait une organisation interne avec 128 banques, contre moins d'une dizaine pour la DDR de l'époque et environ 32 banques pour la DDR5 moderne. Elle contenait aussi quelques circuits pour gérer son rôle de mémoire cache, mais rien en ce qui concerne la gestion des tags eux-mêmes. Sur les processeurs de microarchitecture ''Broadwell'', les tags étaient placés dans le CPU et précisément dans le cache L3. À chaque accès mémoire au cache L3, les tags du cache L4 étaient consultés en parallèle. De fait, l'accès au cache L4 était assez rapide, malgré le fait que les données étaient dans une puce à part. Ajoutons à cela que le processeur et ''Crystal Well'' n'avaient pas la même finesse de gravure ni la même technologie de fabrication. Les tags étaient implémentés avec de la SRAM contre la DRAM pour les données, ce qui fait que la consultation des tags était plus rapide que l'accès aux données. Par la suite, dans certains CPU de microarchitecture ''skylake'', les tags ont été déplacés en-dehors du processeur pour finir dans le contrôleur mémoire. En faisant cela, le cache L4 pouvait être utilisé par autre chose que le processeur, et notamment par la carte graphique intégrée au CPU. Avec ''broadwell'', le fait que les tags étaient consultés en cas d'accès au L3 empêchait au GPU intégré de consulter le cache L4. Mais en déplaçant les tags dans le contrôleur mémoire, ce n'est plus le cas vu que la carte graphique a aussi accès au bus mémoire. Par contre, le temps d'accès augmente comparé à la solution précédente. On n'accède pas aux tags du L4 en parallèle du L3 : à la place, il faut consulter les tags du L3, détecter un défaut de cache L3, et ensuite accèder aux tags. ===Les caches RAM-configurables=== Un autre avantage des caches splittés est qu'on peut les modifier pour servir à la fois de mémoire cache, mais aussi de ''local store'', de mémoire RAM de petite taille. Le fonctionnement est assez simple à comprendre. Lors d'un accès au cache, on accède aux tags, puis à la RAM interne au cache. Lors d'un accès au ''local store'', on contourne l'accès au tags et on accède à la RAM interne au cache directement. Il s'agit de la technique du '''cache RAM-configurable''. L'usage de cache RAM-configurable est fréquent sur les cartes graphiques récentes, qui incorporent un ou plusieurs processeurs multicoeurs, dont le cache L1 de données est un cache RAM-configurable. [[File:Hydride cache - local store.png|centre|vignette|upright=2.0|Hydride cache - local store]] ===La compression de cache=== Une autre optimisation permise par les ''phased caches'' est l'implémentation de techniques de '''compression de cache''', qui visent à compresser des lignes de cache. L'intérêt est qu'on peut stocker plus de données dans le cache, à capacité égale. L'inconvénient est qu'on doit compresser/décompresser les lignes de cache, ce qui demande un circuit en plus et allonge les temps d'accès. En effet, le temps mis pour compresser/décompresser une ligne de cache s'ajoute au temps d'accès. Aussi, la compression de cache sert surtout pour les caches de bas niveau dans la hiérarchie mémoire, les gros caches aux temps d'accès assez longs. Une première technique, assez simple à implémenter et peu couteuse en circuit, est celle de la '''compression des lignes de cache nulles'''. Elle compresse uniquement les lignes de cache qui ne contiennent que des zéros. L'idée est qu'on ajoute, dans la mémoire des tags, un bit de contrôle pour chaque ligne de cache appelé le bit ''null''. Il indique si la ligne de cache ne contient que des zéros. Quand on lit une ligne de cache, la mémoire des tags est accédée et on vérifie le bit ''null'' : s'il vaut 1, on n'accède pas à la mémoire cache de données et un multiplexeur envoie un zéro sur le port de lecture. Le bit ''null'' est fixé lors de l'écriture d'une ligne de cache : elle passe dans un comparateur avec zéro relié à la mémoire des tags. La comparaison avec zéro peut se faire en parallèle de l'écriture ou avant (dans ce cas, on n'écrit pas la ligne de cache dans le cache). Les autres techniques de compression de cache permettent de compresser autre chose que des lignes de cache nulles. L'idée est qu'une ligne de cache physique peut par moment mémoriser plusieurs lignes de caches compressées. Par exemple, prenons un cache dont les lignes de cache font 64 octets. Il est possible de compresser deux lignes de cache pour qu'elles fassent chacune 32 octets, et les stocker dans une seule ligne de cache. Les deux lignes de cache auront des tags différents, mais pointeront sur la même ligne de cache physique. Et cela demande d'utiliser un ''phased cache'' dont la mémoire pour les tags est plus grande que la mémoire pour les données. Il n'y a donc plus une bijection entre tags et ligne de cache, mais une relation surjective. Chose qui n'est possible qu'avec un ''phased cache''. De plus, des bits de contrôles associés à chaque ''tag'' indiquent où se trouvent les lignes de cache compressées dans la ligne de cache : est-ce que c'est les 32 octets de poids fort ou de poids faible ? [[File:Compression de cache.png|centre|vignette|upright=2|Compression de cache]] Il ne semble pas que les techniques de compression de cache soient implémentées sur les processeurs modernes. Aucun n'utilise de compression de cache, à ma connaissance. Il faut dire que les techniques connues sont de mauvais compromis : le temps d'accès du cache augmente beaucoup, le cout en circuit pourrait être utilisé pour un cache non-compressé mais plus grand. Et notons que la compression de cache ne marche que si les données peuvent se compresser. Si ce n'est pas le cas, une partie de la mémoire des tags est inutilisée. Une revue de la littérature académique sur la compression de cache est disponible via ce lien, pour les curieux : * [https://inria.hal.science/hal-03285041 Understanding Cache Compression, par Carvalho et Seznec]. ==L'adressage physique ou logique des caches== Le cache utilise les adresses à lire/écrire pour déterminer s'il a une copie de la donnée en son sein. Mais l’interaction entre caches et mémoire virtuelle donne lieu à un petit problème : l'adresse utilisée est-elle une adresse virtuelle/logique ou physique ? La réponse varie suivant le processeur : certains caches utilisent l'adresse virtuelle, tandis que d'autres prennent l'adresse physique. On parle de cache '''virtuellement tagué''' dans le premier cas et de cache '''physiquement tagué''' dans le second. {| |[[File:Cache tagué virtuellement.png|vignette|Cache tagué virtuellement.]] |[[File:Cache tagué physiquement.png|vignette|Cache tagué physiquement.]] |} ===L'accès à un cache physiquement/virtuellement tagué=== La manière d'accéder à un cache dépend de s'il est virtuellement ou physiquement tagué. Il faut utiliser l'adresse virtuelle pour les premiers, physique pour les seconds. Avec un cache virtuellement tagué, l'adresse logique peut être envoyée directement au cache. La MMU ne traduit les adresses que s'il faut accéder à la mémoire RAM. Ces caches sont donc plus rapides. Avec un cache physiquement tagué, le processeur doit traduire l'adresse logique en adresse physique dans la MMU, avant d'accéder au cache. La traduction d'adresse se fait soit en accédant à une table des pages en mémoire RAM, soit en accédant à un cache spécifiquement dédié à accélérer la traduction d'adresse, la TLB (''Translation Lookaside Buffer''). Dans la quasi-totalité des cas, la traduction d'adresse passe par la TLB, ce qui fait qu'elle est raisonnablement rapide. Toujours est-il que chaque accès au cache demande d'accéder à la TLB et de faire la traduction d'adresse avant d'accéder au cache. L'accès est donc plus lent que sur les caches virtuellement tagués, où les accès sont plus directs. [[File:Virtual and Physical addressing.svg|centre|vignette|upright=2|Cache tagué virtuellement versus physiquement tagué.]] ===Les défauts des caches virtuellement tagués=== Les caches physiquement tagués sont moins rapides que les caches virtuellement adressés. Pourtant, les caches virtuellement tagués sont peu fréquents sur les processeurs modernes. Et la raison est assez intéressante : c'est une question d'adresses homonymes et synonymes. ====Les droits d'accès doivent être vérifiés lors d'un accès au cache==== Un premier problème est que la protection mémoire est compliquée avec de tels caches. Rappelons que certaines portions de mémoire sont accessibles seulement en lecture, ou sont interdites en écriture, sont inexécutables, etc. Ces droits d'accès sont gérés par la MMU, qui vérifie pour chaque accès mémoire que l'accès est autorisé. En bypassant la MMU, l'accès au cache virtuellement tagué ne permet pas de faire ces vérifications. Il est possible de charger une donnée en lecture seule dans le cache, mais d'y faire des accès en écriture pour les accès ultérieurs. Les solutions à cela sont multiples. La première consiste à consulter la MMU en parallèle de l'accès au cache. L'accès au cache est alors réalisé de manière spéculative, et est ensuite confirmé/annulé une fois que la MMU a rendu son verdict. Les performances du cache restent alors les mêmes : l'accès à la MMU se fait en parallèle de l'accès au cache, pas avant. Une autre solution est d'ajouter les droits d'accès en question dans la ligne de cache, dans les bits de contrôle situés après le Tag. Chaque accès au cache récupère ces bits de contrôle et vérifie si l'accès est autorisé. L'inconvénient est que les lignes de cache deviennent plus longues, les droits d'accès sont dupliqués entre MMU et cache. Mais si le budget en transistor suit, ce n'est rien d'insurmontable. ====Les adresses homonymes perturbent la gestion du cache==== Pour rappel, une adresse logique homonyme correspond à plusieurs adresses physiques différentes. Elles surviennent quand chaque programme a son propre espace d'adressage. Dans ce cas, une adresse logique correspondra à une adresse physique différente par programme.Une autre manière de voir les choses est qu'il y a en réalité deux adresses homonymes, qui ont la même valeur, mais appartiennent à des espaces d'adressage différentes. Et c'est cette seconde interprétation que nous allons utiliser. Les caches doivent gérer ces adresses homonymes et faire en sorte que la lecture/écriture d'une adresse homonyme se fasse à la bonne adresse physique, dans la bonne ligne de cache. Et autant un cache physiquement tagué n'a aucun problème avec ça, vu qu'il ne gère que des adresses physiques, autant des problèmes surviennent avec les caches virtuellement tagués. Le problème est que les caches virtuellement tagués doivent faire la différence entre deux adresses homonymes de même valeur. Pour corriger ces problèmes, il existe deux grandes méthodes. La première méthode est simple : '''vider les caches''' en changeant de programme. Leur contenu est rapatrié en mémoire RAM, puis les caches sont remis à zéro. Le vidage du cache recopie les lignes de cache ''dirty'' (modifiées) en RAM, puis efface/invalide tout le cache. C'est à cela que servent les instructions ''clean'' et d'invalidation vues plus haut, elles ont été inventées pour cette situation précise. Lorsque le système d'exploitation déclenche une commutation de contexte, à savoir qu'il change le programme en cours d'exécution, le processeur vide tous les caches du processeur. Les interruptions font la même chose, elles vide tous les caches du processeur. Une seconde méthode numérote chaque programme en cours d'exécution, chaque processus. Le numéro attribué est spécifique à chaque processus, ce qui fait qu'il est appelé un '''identifiant de processus CPU'''. Le processeur mémorise l'identifiant du programme en cours d'exécution dans un registre dédié. L'identifiant de processus CPU est utilisé lors des accès mémoire. Chaque ligne de cache contient le numéro de l'espace d'adressage associé, dans son ''tag''. Lors de chaque accès mémoire, l'ID du registre est comparé à l'ID de la ligne de cache accédée, pour vérifier que l'accès mémoire accède à la bonne donnée. Cette méthode n'est pas très économe en termes de transistors. L'usage d'identifiant de processus CPU est clairement meilleure en termes de performance, les commutations de contexte sont plus rapides. Par contre, le budget en transistor est plus important. Un autre défaut de cette méthode est que l'identifiant de processus est généralement codé sur une dizaine de bits, alors que le système d'exploitation utilise des identifiants de processus beaucoup plus larges, de 32 à 64 bits sur les CPU 32/64 bits. L'OS doit gérer la correspondance entre identifiants de processus CPU et ceux de l'OS. Parfois, pour cette raison, les OS n'utilisent pas toujours ce système d'identifiant de processus CPU. ====Les adresses synonymes perturbent aussi la gestion du cache==== La gestion des adresses synonymes est aussi un gros problème sur les caches virtuellement tagués. Pour rappel, il s'agit du cas où des adresses logiques différentes pointent vers la même adresse physique. Typiquement, quand deux programmes se partagent un morceau de mémoire, ce morceau correspondra à des adresses synonymes dans les deux espaces d'adressage. Mais il arrive que l'on ait des adresses synonymes dans le même espace d'adressage, ce n'est pas si rare ! Autant les adresses synonymes ne posent aucun problème avec les caches physiquement tagués, ce n'est pas le cas avec les caches virtuellement adressés. Sur ces caches, deux adresses logiques synonymes vont tomber dans deux lignes de cache différentes. Corriger ce problème demande d'ajouter des circuits annexes pour détecter les adresses synonymes, qui sont vraiment complexes et ont un cout en termes de performance. Aussi, les caches virtuellement tagués sont très peu utilisés sur les processeurs modernes. ===Les caches virtuellement adressés, mais physiquement tagués=== Si les caches physiquement et virtuellement tagués ont des défauts, il existe un intermédiaire qui est un bon compromis entre ces deux extrêmes. Il s'agit des '''caches virtuellement adressés - physiquement tagués''', aussi appelés '''caches pseudo-virtuels'''. Pour comprendre comment ils fonctionnent, précisons que ces caches sont soit des caches ''direct-mapped'', soit des caches associatifs par voie (composés de plusieurs RAM ''direct-mapped'' accédées en parallèle, plusieurs voies). L'accès à ce genre de cache se fait en deux temps : on accède à un ou plusieurs RAM ''direct-mapped'' et on vérifie ensuite les ''Tags'' pour sélectionner la bonne voie. Sur les caches ''direct-mapped'', on n'a qu'une seule RAM ''direct-mapped''. Sur les caches associatifs, on a plusieurs RAM ''direct-mapped'', appelées des voies, qui sont accédées en parallèle. L'accès se fait donc en deux étapes : adresser les RAM ''direct-mapped'' avec un indice, vérifier les ''tags'' avec le reste de l'adresse. Une autre chose à rappeler est que l'adresse logique est composée de deux parties : un numéro de page logique qui indique dans quel page se situe l'adresse, un décalage/''offset'' qui indique la position de l'adresse dans la page. La traduction d'adresse transforme le numéro de page logique en numéro de page physique, mais laisse le décalage intouché. L'idée est d'utiliser le décalage pour adresser les RAM avec le décalage, tandis que le numéro de page sert de ''tag''. Le décalage est découpé en deux lors de l'accès au cache : les bits de poids fort forment l'indice (l'adresse envoyée à la voie), les bits de poids faible donnent la position de l'adresse dans la ligne de cache. L'idée est d'utiliser un numéro de page physique pour les ''tags'', mais d'adresser les voies avec le décalage logique. Les deux servent à des instants différents : vérification des ''tags'' pour l'adresse physique, accès aux voies pour l'adresse logique. Ainsi, le problème des adresses synonymes ou homonymes est résolu par l'utilisation de l'adresse physique pour les tags. Par contre, l'accès au cache est plus rapide, car on utilise l'adresse logique pour la première étape. Le processeur accède à la TLB et récupère l'adresse physique pendant que l'on adresse les voies, les deux sont faits en parallèle, ce qui fait que tout se passe comme si l'accès à la TLB était gratuit. La TLB étant assez rapide comparé au cache, l'adresse physique est disponible quand on doit faire la comparaison avec les ''tags''. [[File:Virtual - Physical - Pseudo Virtual addressing.svg|centre|vignette|upright=2|Adressage pseudo virtuel des caches.]] Il s'agit d'un excellent compromis entre performance et correction des problèmes des adresses synonymes/homonymes. Tous les caches des processeurs haute performance utilisent cette méthode, au moins pour leurs caches L1. Les caches L2 tendent à utiliser des caches physiquement adressés, pour lesquels la latence d'accès est suffisante pour qu'on accède à la TLB en amont. La raison est assez simple à expliquer, elle provient d'une contrainte assez précise sur le calcul de l'indice. La conséquence est qu'un cache ''direct-mapped'' ne peut pas dépasser la taille d'une page, soit 4 kibioctets sur les ordinateurs actuels. Sur les caches associatifs, on peut dépasser cette limite en augmentant le nombre de voies, mais la taille maximale d'une voie reste celle d'une page. Cette contrainte n'est pas trop grave sur les caches de petite taille, dont les caches L1. La plupart d'entre eux ont trouvé un compromis idéal avec moins d'une dizaine de voies par cache, chacun de 4 kibioctets, ce qui donne des caches allant de 16 à 64 kibioctets, soit entre 4 et 16 voies. Par contre, un cache de grande taille doit utiliser un grand nombre de voies, ce qui est peu pratique. Aussi, cette technique de caches pseudo-virtuels n'est pas toujours appliquée sur les caches L2, qui sont physiquement adressés. Il faut dire qu'on accède au cache L2 lors d'un défaut dans le cache L1, et l'adresse physique est disponible à ce moment-là, elle a déjà été récupérée lors de l'accès au cache L1. On peut donc l'utiliser pour adresser le cache L2 sans perte de performance. ==Le remplacement des lignes de cache== Lorsqu'un cache est rempli et qu'on charge une nouvelle donnée dedans, il faut faire de la place pour cette dernière. Dans le cas d'un cache directement adressé, il n'y a rien à faire vu que la ligne de cache à évincer est déterminée lors de la conception du cache. Mais pour les autres caches, la donnée peut aller dans n'importe quelle ligne ou voie. Or, le choix des données à rapatrier en RAM doit être le plus judicieux possible : on doit virer de préférence des données inutiles. Rapatrier une donnée qui sera surement utilisée sous peu est inutile, et il vaudrait mieux supprimer des données qui ne serviront plus ou alors dans longtemps. Il existe différents algorithmes spécialement dédiés à résoudre ce problème efficacement, directement câblés dans les unités de gestion du cache. Certains sont vraiment très complexes, aussi je vais vous présenter quelques algorithmes particulièrement simples. Mais avant de voir ces algorithmes, il faut absolument que je vous parle d'une chose très importante. Quel que soit l'algorithme en question, il choisit la ligne de cache à évincer et recopie son contenu dans la RAM. Ce qui demande d'identifier et de sélectionner une ligne de cache parmi toutes les autres. Pour cela, le circuit de remplacement attribue une adresse chaque ligne de cache ! Vous avez bien vu : chaque ligne de cache est numérotée par une adresse, interne au cache. ===Le remplacement aléatoire=== Premier algorithme : la donnée effacée du cache est choisie au hasard ! C'est contre-intuitif, mais cet algorithme donne des résultats assez honorables, en plus d'utiliser très peu de portes logiques (un générateur de nombres pseudo-aléatoire est un circuit assez simple). Généralement, les défauts de cache sont séparés par un nombre assez important et irrégulier de cycles d'horloge. Dans ces conditions, cette technique donne un bon résultat. ===FIFO : first in, first out=== Avec l'algorithme FIFO, la donnée effacée du cache est la plus ancienne, celle chargée dans le cache avant les autres. Cet algorithme est très simple à implémenter en circuit, concevoir une mémoire de type FIFO n'étant pas très compliqué, comme on l’a vu dans le chapitre dédié à ce type de mémoires. Et on peut dire que dans le cas d'un cache, l'implémentation est encore plus simple et se contente d'un seul registre/compteur. Typiquement, il suffit d'ajouter un registre qui mémorise où se situe la donnée la plus récente. Toute insertion d'une nouvelle donnée se fait à l'adresse suivante, ce qui demande juste d'incrémenter le registre avant d'utiliser son contenu pour l'accès mémoire. [[File:Algorithme FIFO de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme FIFO de remplacement des lignes de cache.]] Cet algorithme possède une petite particularité sur les caches associatifs par voie : en augmentant le nombre d'ensembles, les performances peuvent se dégrader : c'est ce qu'on appelle l''''anomalie de Bélády'''. ===MRU : most recently used=== Avec l'algorithme MRU, la donnée remplacée est celle qui a été utilisée le plus récemment. Cet algorithme s'implémente simplement avec un registre, dans lequel on place le numéro de la dernière ligne de cache utilisée. Cet algorithme de remplacement est très utile quand un programme traverse des tableaux du premier élément jusqu'au dernier : les données du tableau sont rarement réutilisées, rendant le cache inutile. Il est prouvé que dans ces conditions, l'algorithme MRU est optimal. Mais dans toutes les autres conditions, cet algorithme a des performances assez misérables. ===LFU : least frequently used=== Avec l'algorithme LFU, la donnée supprimée est celle qui est utilisée le moins fréquemment. Cet algorithme s'implémente en associant un compteur à chaque ligne de cache, qui est incrémenté à chaque accès mémoire. La ligne la moins récemment utilisée est celle dont le compteur associé a la plus petite valeur. Implémenter cet algorithme prend pas mal de transistors, car il faut rajouter autant de compteurs qu'il y a de lignes de cache, en plus d'un circuit pour comparer les compteurs et d'un encodeur. [[File:Algorithme LFU de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme LFU de remplacement des lignes de cache]] ===LRU : least recently used=== Avec l'algorithme LRU, la donnée remplacée est celle qui a été utilisée le moins récemment. Cet algorithme se base sur le principe de localité temporelle, qui stipule qu'une donnée accédée récemment a de fortes chances d'être réutilisée dans un futur proche. Et inversement, la donnée la moins récemment utilisée du cache est celle qui a le plus de chance de ne servir à rien dans le futur. Autant la supprimer en priorité pour faire de la place à des données potentiellement utiles. Implémenter l'algorithme LRU peut se faire de différentes manières, qui ont pour point commun d'enregistrer les accès au cache pour en déduire la ligne la moins récemment accédée. La manière la plus simple demande d'utiliser un compteur pour chaque ligne de mémoire cache, un peu comme le LFU. La différence avec le LFU est que le compteur n'est pas incrémenté lors d'un accès mémoire. À la place, ce compteur est incrémenté régulièrement, chaque incrémentation ayant lieu en même temps pour tous les compteurs. Quand un bloc est chargé dans le cache, ce compteur est mis à zéro. Quand une ligne de cache doit être remplacée, un circuit va vérifier la valeur de tous les compteurs : la ligne LRU (la moins récemment utilisée), est celle dont le compteur a la valeur la plus haute. Le circuit est composé d'un paquet de comparateurs, et d'un encodeur, comme pour l'agorithme LFU. ===Les approximations du LRU=== Implémenter le LRU demande un nombre de transistors proportionnel au carré du nombre de lignes de cache. Autant dire que le LRU devient impraticable sur de gros caches. Ce qui fait que les processeurs modernes implémentent des variantes du LRU, moins couteuses en transistors, qui donnent un résultat approximativement semblable au LRU. En clair, ils ne sélectionnent pas toujours la ligne de cache la moins récemment utilisée, mais une ligne de cache parmi les moins récemment utilisées. Ce n'est pas un problème si grave que cela car les lignes les moins récemment utilisées ont toutes assez peu de chance d'être utilisées dans le futur. Entre choisir de remplacer une ligne qui a 0,5 % de chances d'être utilisée dans le futur et une autre qui a une chance de seulement 1 %, la différence est négligeable en termes de taux de succès. Mais les gains en termes de circuits ou de temps d'accès au cache de ces algorithmes sont très intéressants. L'algorithme le plus simple consiste à couper le cache (ou chaque voie s'il est associatif) en plusieurs sections. L'algorithme détermine la section la moins récemment utilisée, avant de choisir aléatoirement une ligne de cache dans cette section. Pour implémenter cet algorithme, il nous suffit d'un registre qui mémorise le morceau le moins récemment utilisé, et d'un circuit qui choisit aléatoirement une ligne de cache. Cette technique s'adapte particulièrement bien avec des caches associatifs à voies : il suffit d'utiliser autant de morceaux que de voies. Autre algorithme, un peu plus efficace : le '''pseudo-LRU de type M'''. Cet algorithme attribue un bit à chaque ligne de cache, bit qui sert à indiquer de façon approximative si la ligne de cache associée est une candidate pour un remplacement ou non. Il vaut 1 si la ligne n'est pas une candidate pour un remplacement et zéro sinon. Le bit est mis à 1 lorsque la ligne de cache associée est lue ou écrite. Évidemment, au fil du temps, toutes les lignes du cache finiront par avoir leur bit à 1. Lorsque cela arrive, l'algorithme remet tous les bits à zéro, sauf pour la dernière ligne de cache accédée. L'idée derrière cet algorithme est d'encercler la ligne de cache la moins récemment utilisée au fur et à mesure des accès. L'encerclement commence lorsque l'on remet tous les bits associés aux lignes de cache à 0, sauf pour la ligne accédée en dernier. Au fur et à mesure des accès, l'étau se resserre autour de la ligne de cache la moins récemment utilisée. Après un nombre suffisant d'accès, l'algorithme donne une estimation particulièrement fiable. Et comme les remplacements de lignes de cache sont rares comparés aux accès aux lignes, cet algorithme finit par donner une bonne estimation avant qu'on ait besoin d'effectuer un remplacement. Le dernier algorithme d'approximation, le '''PLURt''', se base sur ce qu'on appelle un arbre de décision. Il a besoin de n − 1 bits pour déterminer la ligne LRU. Ces bits doivent être organisés en arbre, comme illustré plus bas. Chacun de ces bits sert à dire : le LRU est à ma droite ou à ma gauche : il est à gauche si je vaux 0, et à droite si je vaux 1. Trouver le LRU se fait en traversant cet arbre, et en interprétant les bits un par un. Au fur et à mesure des lectures, les bits sont mis à jour dans cet arbre, et pointent plus ou moins bien sur le LRU. La mise à jour des bits s'effectue lors des lectures et écritures : quand une ligne est lue ou écrite, elle n'est pas la ligne LRU. Pour l'indiquer, les bits à 1 qui pointent vers la ligne de cache sont mis à 0 lors de la lecture ou écriture. {| |[[File:Organisation des bits avec l'algorithme PLURt.jpg|vignette|Organisation des bits avec l'algorithme PLURt.]] |[[File:Ligne de cache pointée par les bits de l'algorithme.png|vignette|Ligne de cache pointée par les bits de l'algorithme.]] |} ===LRU amélioré=== L'algorithme LRU, ainsi que ses variantes approximatives, sont très efficaces tant que le programme respecte relativement bien la localité temporelle. Par contre, Le LRU se comporte assez mal dans les circonstances ou la localité temporelle est mauvaise mais où la localité spatiale est respectée, le cas le plus emblématique étant le parcours d'un tableau. Pour résoudre ce problème, des variantes du LRU existent. Une variante très connue, l''''algorithme 2Q''', utilise deux caches : un cache FIFO pour les données accédées une seule fois et un second cache LRU. Évidemment, les données lues une seconde fois sont migrées du cache FIFO vers le cache LRU, ce qui n'est pas très pratique. Les processeurs n'utilisent donc pas cette technique, mais celle-ci est utilisée dans les caches de disque dur. D'autres variantes du LRU combinent plusieurs algorithmes à la fois et vont choisir lequel de ces algorithmes est le plus adapté à la situation. Notre cache pourra ainsi détecter s’il vaut mieux utiliser du MRU, du LRU, ou du LFU suivant la situation. ==Les écritures dans le cache : gestion et optimisations== Les écritures se font à une adresse mémoire bien précise, qui peut ou non être chargée dans le cache. Si la donnée à écrire est chargée dans le cache, elle est modifiée directement dans le cache, mais elle ne l'est pas forcément en mémoire RAM. Suivant le processeur, les écritures sont ou non propagées en mémoire RAM. Il existe deux stratégies d'écritures, appelées respectivement le ''write-back'' et le ''write-through''. Avec un cache ''write-back'', si la donnée à mettre à jour est présente dans le cache, on écrit dans celui-ci sans écrire dans la mémoire RAM. Dans ces conditions, une donnée n'est enregistrée en mémoire que si celle-ci quitte le cache, ce qui évite de nombreuses écritures mémoires inutiles. [[File:Cache write-through.png|centre|vignette|upright=2|Cache write-through.]] Avec les caches '''Write-Through''', toute écriture dans le cache est propagée en RAM. Cette stratégie augmente le nombre d'écritures dans la mémoire RAM, ce qui peut saturer le bus reliant le processeur à la mémoire. Les performances de ces caches sont donc légèrement moins bonnes que pour les caches ''write back''. Par contre, ils sont utiles dans les architectures avec plusieurs processeurs, comme nous le verrons dans les chapitres sur les architectures multiprocesseurs. [[File:Cache write-back.png|centre|vignette|upright=2|Cache write-back.]] ===Les caches ''Write-through''=== Sans optimisation particulière, on ne peut écrire dans un cache ''write-through'' pendant qu'une écriture en RAM a lieu en même temps : cela forcerait à effectuer deux écritures simultanées, en comptant celle imposée par l'écriture dans le cache. Pour éviter cela, certains caches ''write-through'' intègrent un '''tampon d’écriture''', qui sert de file d'attente pour les écritures en RAM. C'est une mémoire FIFO dans laquelle on place temporairement les données à écrire en RAM, où elles attendent en attendant que la RAM soit libre. Grâce à lui, le processeur peut écrire dans un cache même si d'autres écritures sont en attente dans le tampon d'écriture. Par souci d'efficacité, des écritures à la même adresse en attente dans le tampon d’écriture sont fusionnées en une seule. Cela fait un peu de place dans le tampon d’écriture, et lui permet d'accumuler plus d'écritures avant de devoir bloquer le cache. Il est aussi possible de fusionner des écritures à adresses consécutives de la mémoire en une seule écriture en rafales. Dans les deux cas, on parle de '''combinaison d'écriture'''. Mais la technique du tampon d'écriture a cependant un léger défaut qui se manifeste dans une situation bien précise : quand le processeur veut lire une donnée en attente dans le tampon d’écriture. La première manière de gérer cette situation est de mettre en attente la lecture tant que la donnée n'a pas été écrite en mémoire RAM. On peut aussi lire la donnée directement dans le tampon d'écriture, cette optimisation portant le nom de '''''store-to-load forwading'''''. Dans tous les cas, il faut détecter le cas où une lecture accède à une donnée dans le tampon d'écriture. À chaque lecture, l'adresse à lire est envoyée au tampon d'écriture, qui vérifie si une écriture en attente se fait à cette adresse. Pour cela, le tampon d’écriture doit être un cache, dont chaque entrée mémorise une écriture. Chaque ligne de cache contient la donnée à écrire, et le tag de la ligne de cache contient l'adresse où écrire la donnée. Notons que cache d'écriture a une politique de remplacement de type FIFO, le tampon d'écriture non-optimisé étant une mémoire FIFO. ===Les caches ''Write-back''=== Les caches ''write-back'' ont beau avoir des performances supérieures à celles des caches ''write-through'', il existe des optimisations qui permettent d'améliorer leurs performances. Ces optimisations consistent à ajouter des caches spécialisés à côté du cache proprement dit. Ces caches permettent de mémoriser des données qui sont éliminées du cache par les algorithmes de remplacement de ligne cache, sans pour autant faire une écriture en RAM. En suivant la procédure habituelle de remplacement des lignes de cache, on doit rapatrier la ligne en RAM avant d'en charger une nouvelle. On peut améliorer la situation en faisant l'inverse : on charge la nouvelle ligne pendant que l'ancienne donnée est rapatriée en RAM. Ainsi, la nouvelle ligne est disponible plus tôt pour le processeur, diminuant son temps d'attente. Pour implémenter cette technique, on doit mémoriser l'ancienne ligne de cache temporairement dans un '''cache d’éviction''' (ou ''write-back buffer''). [[File:Cache d’éviction.png|centre|vignette|upright=2|Cache d’éviction]] Les caches directement adressés ou associatifs par voie possèdent aussi un tampon d’écriture amélioré. Pour limiter les défauts par conflit de ces caches, des scientifiques ont eu l'idée d'insérer un cache pour stocker les données virées du cache. En faisant ainsi, si une donnée est virée du cache, on peut alors la retrouver dans ce cache spécialisé. Ce cache s'appelle le '''cache de victime'''. Ce cache de victime est géré par un algorithme de suppression des lignes de cache de type FIFO. Petit détail : ce cache utilise un tag légèrement plus long que celui du cache directement adressé au-dessus de lui. L'index de la ligne de cache doit en effet être contenu dans le tag du cache de victime, pour bien distinguer deux adresses différentes, qui iraient dans la même ligne du cache juste au-dessus. [[File:Victim Cache Implementation Example.svg|centre|vignette|upright=1|Cache de victime.]] ===La configuration du fonctionnement du cache=== Sur de nombreux processeurs, il est possible de configurer la mémoire cache pour qu'elle fonctionne soit en mode ''write-back'', soit en mode ''write-through''. Pour cela, les processeurs modernes incorporent des '''registres de configuration du cache'''. Le terme ''registre de configuration du cache'' est assez transparent et indique bien quel est leur rôle. Ils configurent comment le cache est utilisé et permettent notamment de configurer le cache pour dire s'il doit fonctionner en mode ''write-back'' ou ''write-through''. Ils permettent aussi d'activer ou de désactiver la combinaison sur écriture. Les registres en question sont configurés soit par le BIOS, soit par le système d'exploitation. Ce sont des registres protégés, que les applications ne peuvent pas configurer, elles n'en ont pas le droit. Typiquement, ils ne sont accessibles en écriture qu'en mode noyau. Sur les processeurs x86, les registres de configuration du cache sont appelés des '''''Memory type range registers''''' (''MTRRs''). Les MTRRs sont assez nombreux, et il y a notamment une différence entre mode réel et protégé. Si vous vous souvenez des chapitres sur le mode d'adressage et la mémoire virtuelle, vous vous souvenez que les processeurs x86 incorporent plusieurs modes de fonctionnement. En mode réel, le processeur ne peut adresser qu'un mébioctet de RAM, avec un système de segmentation particulier. En mode protégé, le processeur peut adresser toute la mémoire et la segmentation fonctionne différemment, quand elle n'est pas simplement désactivée. Les MTRRs sont séparés en deux : ceux pour le mode réel, ceux pour le mode protégé. Les MTRRs fixes sont ceux qui configurent le cache en mode réel, ils étaient utilisés pour gérer l'accès au BIOS, à la mémoire VGA de la carte graphique, et quelques autres accès aux entrées-sorties basiques gérées nativement par le BIOS. Pour le mode protégé, les processeurs au-delà du 386 incorporent des MTRRs variables, qui servent pour les autres entrées-sorties en général, notamment les périphériques PCI, la mémoire vidéo de la carte graphique, et j'en passe. De nos jours, les registres de configuration du cache sont désuets et cette fonctionnalité est gérée directement par la mémoire virtuelle. La table des pages contient, pour chaque page mémoire, des bits de contrôle qui disent si la page mémoire est cacheable ou non. Le contournement de cache est alors géré par le système de mémoire virtuelle, le cache de TLB et tout ce qui va avec. ===L’allocation sur écriture=== Que faire quand une écriture modifie une donnée qui n'est pas dans le cache ? Doit-on écrire la donnée dans le cache, ou non ? Si la donnée est écrite dans le cache, on dit que le cache fait une '''allocation sur l'écriture''' (ou ''write-allocate''). Certains caches effectuent une telle allocation sur écriture, mais d'autres ne le font pas ou du moins pas systématiquement. L’allocation sur écriture peut se décliner en deux sous-catégories : le '''chargement à la demande''' et l''''écriture immédiate'''. Dans le premier cas, on charge la donnée à modifier dans le cache, et on la remplace avec la donnée écrite. Dans l'écriture immédiate, l'écriture a lieu directement dans le cache et la donnée à modifier n'est pas chargée dans le cache. Évidemment, seule une portion de la ligne de cache contient la donnée écrite (valide), et le reste contient des données invalides. Le cache doit savoir quelles sont les portions du cache qui sont valides : cela demande d'utiliser un ''sector cache''. [[File:Write-back with write-allocation.svg|centre|vignette|upright=2|Cache Write-back avec allocation sur écriture.]] Sans allocation sur écriture, l'écriture est transférée directement aux niveaux de cache inférieurs ou à la mémoire si la donnée à modifier n'est pas dans le cache. Certains caches de ce genre utilisent une petite optimisation : lors de toute écriture, ils supposent que l'écriture donnera un succès de cache. Si c'est le cas, la ligne de cache qui contient la donnée est mise à jour avec la donnée à écrire. Mais si ce n'est pas le cas, la ligne de cache est invalidée, et l'écriture est transférée directement à la mémoire ou aux niveaux de cache inférieurs. [[File:Write-through with no-write-allocation.svg|centre|vignette|upright=2|Cache Write-through sans allocation sur écriture.]] ===La cohérence des caches=== Il arrive parfois que la mémoire d'un ordinateur soit mise à jour, sans que les modifications soient répercutées dans les mémoires cache. Dans ce cas, le cache contient une donnée périmée. Or, un processeur doit toujours éviter de se retrouver avec une donnée périmée et doit toujours avoir la valeur correcte dans ses caches : cela s'appelle la '''cohérence des caches'''. Il est possible de se retrouver avec des valeurs périmées dans le cache sur les ordinateurs avec plusieurs processeurs, ou si un périphérique écrit en RAM, les modifications ne sont pas répercutées automatiquement dans les mémoires cache. Pour résoudre ce problème, on peut interdire de charger dans le cache des données stockées dans les zones de la mémoire dédiées aux périphériques. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires cache. Autre solution : utiliser le fait que les périphériques déclenchent une interruption matérielle pour laisser le contrôleur DMA accéder à la mémoire. Dans ce cas, il suffit de vider les caches à chaque interruption matérielle. Le processeur peut le faire automatiquement, ou fournir des instructions pour. ==Le ''cache bypassing'' : contourner le cache== Dans certaines situations, le cache n'est pas utilisé pour certains accès mémoire. Diverses techniques permettent en effet d'effectuer des accès mémoire qui contournent le cache, qui ne passent pas par le cache. Ils sont utilisés quand l'accès en cache fait que des instructions normales ne fonctionnent pas. Par exemple, de tels accès directs à la RAM sont notamment utilisés pour l'implémentation d'instructions atomiques, une classe d'instructions spécifiques utilisées sur les processeurs multicœurs, dont nous parlerons dans plusieurs chapitres. Mais ils sont aussi utilisés pour l'accès aux périphériques, ce que nous allons voir maintenant. ===Accéder aux périphériques demande de contourner le cache=== Pour rappel, un périphérique (au sens d'entrée-sortie) contient des registres d’interfaçage qui ont une adresse au même titre que les cases mémoire. Un périphérique peut à tout instant modifier ses registres d’interfaçage, ce qui se répercute automatiquement dans l'espace d'adressage, mais rien de tout cela n'est transmis au cache. Si les accès aux périphériques passaient par l'intermédiaire du cache, on aurait droit à des problèmes. On aurait encore une fois droit à des problèmes de cohérence des caches. Le problème est géré différemment suivant que l'on utilise un espace d'adressage séparé ou des entrées-sorties mappées en mémoire. La solution est que les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache. Cela demande d'adapter le cache et le processeur. L'implémentation exacte dépend de comment sont adressés les périphériques. Pour rappel, il y a deux solutions pour adresser les périphériques : soit les périphériques disposent d'un espace d'adressage séparé de celui de la mémoire, soit il y un espace d'adressage unique partagé entre processeur et mémoire. Les deux cas donnent des solutions différentes. Avec un espace d'adressage séparé, l'espace d'adressage des périphériques n'est pas caché : aucun accès dans cet espace d'adressage ne passe par le cache. La mémoire cache n'est utilisée que pour l'espace d'adressage des mémoires, rien d'autre. C'est de loin le cas le plus simple : il suffit de concevoir le processeur pour. Il dispose d'instructions séparées pour les accès aux registres d’interfaçage et à la RAM/ROM, les premières ne passent pas par le cache, les autres si. Avec des entrées-sorties mappées en mémoire, la même solution est utilisée, mais dans une version un peu différente. Là encore, les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache, si on veut qu'ils marchent comme ils le doivent. Cela demande d'adapter le cache et le matériel pour que accès aux périphériques mappés en mémoire contournent le cache. Des adresses, voire des zones entières de la mémoire, sont marquées comme étant non-cachables. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires caches. Là encore, le processeur doit être prévu pour : on doit pouvoir le configurer de manière à marquer certaines zones de la RAM comme non-cacheable. Reste qu'il faut marquer des régions de la RAM comme non-cacheable. Pour cela, on améliore les registres de configuration du cache, vus plus haut, afin qu'ils permettent de configurer certaines portions de la RAM pour préciser qu'elles ne doivent pas être mises en cache, qu'il faut activer le contournement de cache pour celles-ci. ===Contourner le cache pour des raisons de performance=== Il arrive que des données avec une faible localité soient chargées dans le cache inutilement. Or, il vaut mieux que ces données transitent directement entre le processeur et la mémoire, sans passer par l'intermédiaire du cache. Pour cela, le processeur peut fournir des instructions d'accès mémoire qui ne passent pas par le cache, à côté d'instructions normales. De telle instructions sont appelées des '''instructions mémoire non-temporelles'''. Non-temporelle, dans le sens : pas de localité temporelle (c.a.d que les données ne seront pas réutilisées plus tard). Mais il existe aussi des techniques matérielles, où le cache détecte à l'exécution les lectures qui gagnent à contourner le cache. La dernière méthode demande d'identifier les instructions à l'origine des défauts de cache, le processeur accédant directement à la RAM quand une telle instruction est détectée. Si une instruction d'accès mémoire fait trop de défauts de cache, c'est signe qu'elle gagne à contourner le cache. L'idée est de mémoriser, pour chaque instruction d'accès mémoire, un historique de ses défauts de cache. Il existe plusieurs méthodes pour cela, mais toutes demandent d'ajouter de quoi mémoriser l'historique des défauts de cache des instructions. L'historique est mémorisé dans une mémoire appelée la '''table d’historique des défauts de lecture''' (''load miss history table''), qui est souvent un cache. L'historique en question est, dans sa version la plus simple, un compteur de quelques bits incrémenté à chaque succès de cache et décrémenté à chaque défaut de cache, qui indique si l'instruction a en moyenne fait plus de défauts ou de succès de cache. La table associe le ''program counter'' d'une instruction mémoire à cet historique. À la première exécution d'une instruction d'accès mémoire, une entrée de cette table est réservée pour l'instruction. Lors des accès ultérieurs, le processeur récupérer les informations associées et décide s'il faut contourner le cache ou non. ==La hiérarchie mémoire des caches== [[File:Cache Hierarchy.png|vignette|Hiérarchie de caches]] On pourrait croire qu'un seul cache est largement suffisant pour compenser la lenteur de la mémoire. Hélas, les processeurs sont devenus tellement rapides que les caches sont eux-mêmes très lents ! Pour rappel, plus une mémoire peut contenir de données, plus elle est lente. Et les caches ne sont pas épargnés. Si on devait utiliser un seul cache, celui-ci serait très gros et donc trop lent. La situation qu'on cherche à éviter avec la mémoire RAM revient de plus belle. Même problème, même solution : si on a décidé de diviser la mémoire principale en plusieurs mémoires de taille et de vitesse différentes, on peut bien faire la même chose avec la mémoire cache. Depuis environ une vingtaine d'années, un processeur contient plusieurs caches de capacités très différentes : les caches L1, L2 et parfois un cache L3. Certains de ces caches sont petits, mais très rapides : c'est ceux auxquels on va accéder en priorité. Viennent ensuite d'autres caches, de taille variable, mais plus lents. Les processeurs ont donc une hiérarchie de caches qui se fait de plus en plus complexe avec le temps. Cette hiérarchie est composée de plusieurs niveaux de cache, qui vont des niveaux inférieurs proches de la mémoire RAM à des niveaux supérieurs proches du processeur. Plus on monte vers les niveaux supérieurs, plus les caches sont petits et rapides. Un accès mémoire dans une hiérarchie de cache fonctionne comme suit : on commence par vérifier si la donnée recherchée est dans le cache le plus rapide, à savoir le cache L1. Si c'est le cas,n on la charge depuis ce cache directement. Si elle n’y est pas, on vérifie si elle est dans le cache de niveau supérieur, le cache L2. Et rebelote ! Si elle n'y est pas, on vérifie le cache du niveau supérieur. Et on répète cette opération, jusqu’à avoir vérifié tous les caches. Si la donnée n'est dans aucun cache, on doit alors aller chercher la donnée en mémoire. [[File:Hiérarchie de caches.png|centre|vignette|upright=2|Hiérarchie de caches]] Il y a des différences assez notables entre chaque niveau de cache. Par exemple, les différents niveaux de cache n'ont pas forcément les mêmes politiques de remplacement des lignes de cache. Le cache L1 a généralement une politique de remplacement simple, très rapide, mais peu efficace. De même, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Le cache le plus proche de la mémoire est appelé le '''cache de dernier niveau''', ''Last Level Cache'' en anglais. Il a parfois des caractéristiques totalement différentes des autres caches. Par exemple, sur les processeurs multicoeurs, le cache L3 n'est techniquement pas dans le cœur, mais fait partie d'un ensemble de circuits reliés, comme le contrôleur mémoire ou l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc. ===Les caches exclusifs et inclusifs=== Notons que du point de vue de cette vérification, il faut distinguer les caches inclusifs et exclusifs. Avec les caches inclusifs, si une donnée est présente dans un cache, alors elle est présente dans les caches des niveaux inférieurs, ce qui implique l'existence de données en doublon dans plusieurs niveaux de cache. À l'opposé, les caches exclusifs font que toute donnée est présente dans un seul cache, pas les autres. Il existe aussi des caches qui ne sont ni inclusifs, ni exclusifs. Sur ces caches, chaque niveau de cache gère lui-même ses données, sans se préoccuper du contenu des autres caches. Pas besoin de mettre à jour les niveaux de cache antérieurs en cas de mise à jour de son contenu, ou en cas d'éviction d'une ligne de cache. La conception de tels caches est bien plus simple. Dans les '''caches exclusifs''', le contenu d'un cache n'est pas recopié dans le cache de niveau inférieur. Il n'y a pas de donnée en double et on utilise 100 % de la capacité du cache, ce qui améliore le taux de succès. Par contre, le temps d'accès est un peu plus long. La raison est que si une donnée n'est pas dans le cache L1, on doit vérifier l'intégralité du cache L2, puis du cache L3. De plus, assurer qu'une donnée n'est présente que dans un seul cache nécessite aux différents niveaux de caches de communiquer entre eux pour garantir que l'on a pas de copies en trop d'une ligne de cache, ce qui peut prendre du temps. [[File:Caches exclusifs.png|centre|vignette|upright=2|Caches exclusifs]] Dans le cas des '''caches inclusifs''', le contenu d'un cache est recopié dans les caches de niveau inférieur. Par exemple, le cache L1 est recopié dans le cache L2 et éventuellement dans le cache L3. Ce genre de cache a un avantage : le temps d'accès à une donnée est plus faible. La raison est qu'il ne faut pas vérifier tout un cache, mais seulement la partie qui ne contient pas de donnée en doublon. Par exemple, si la donnée voulue n'est pas dans le cache L1, on n'est pas obligé de vérifier la partie du cache L2 qui contient la copie du L1. Ainsi, pas besoin de vérifier certaines portions du cache, ce qui est plus rapide et permet de simplifier les circuits de vérification. En contrepartie, l'inclusion fait que qu'une partie du cache contient des copies inutiles, comme si le cache était plus petit. De plus, maintenir l'inclusion est compliqué et demande des circuits en plus et/ou des échanges de données entre caches. [[File:Caches inclusifs.png|centre|vignette|upright=2|Caches inclusifs]] Maintenir l'inclusion demande de respecter des contraintes assez fortes, ce qui ne se fait pas facilement. Premièrement, toute donnée chargée dans un cache doit aussi l'être dans les caches de niveau inférieur. Ensuite, quand une donnée est présente dans un cache, elle doit être maintenue dans les niveaux de cache inférieurs. De plus, toute donnée effacée d'un cache doit être effacée des niveaux de cache supérieurs : si une donnée quitte le cache L2, elle doit être effacée du L1. Ces trois contraintes posent des problèmes si chaque cache décide du remplacement des lignes de cache en utilisant un algorithme comme LRU, LFU, MRU, ou autre, qui utilise l'historique des accès. En effet, dans ce cas, le cache décide de remplacer les lignes de cache selon l'historique des accès, historique qui varie suivant chaque niveau de cache. Par exemple, une donnée rarement utilisée dans le L2 peut parfaitement être très fréquemment utilisée dans le L1 : la donnée sera alors remplacée dans le L2, mais sera maintenue dans le L1. On observe aussi des problèmes quand il existe plusieurs caches à un seul niveau : chaque cache peut remplacer les lignes de cache d'une manière indépendante des autres caches du même niveau, donnant lieu au même type de problème. Pour maintenir l'inclusion, les caches doivent se transmettre des informations qui permettent de maintenir l'inclusion. Par exemple, les caches de niveaux inférieurs doivent prévenir les niveaux de cache supérieurs quand ils remplacent une ligne de cache. De plus, toute mise à jour dans un cache doit être répercutée dans les niveaux de cache inférieurs et/ou supérieurs. On doit donc transférer des informations de mise à jour entre les différents niveaux de cache. Généralement, le contenu des caches d'instruction n'est pas inclus dans les caches de niveau inférieurs, afin d'éviter que les instructions et les données se marchent sur les pieds. Enfin, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Dans ce cas, l'inclusion est plus difficile à maintenir, pour des raisons assez techniques. ===Les caches eDRAM, sur la carte mère et autres=== D'ordinaire, les mémoires caches sont intégrées au processeur, à savoir que cache et CPU sont dans le même circuit imprimé. Les caches sont donc fabriqués avec de la SRAM, seule forme de mémoire qu'on peut implémenter dans un circuit intégré. Intégrer tous les caches dans le processeur est une solution et efficace. Mais certains processeurs ont procédé autrement. [[File:Cache-on-a-stick module.jpg|vignette|Cache-on-a-stick module]] Des processeurs assez anciens incorporaient un cache L1 dans le processeur, mais plaçaient un cache L2 sur la carte mère. Le cache était clippé sur un connecteur sur la carte mère, un peu comme le sont les barrettes de mémoire. On parlait alors de '''''Cache on a stick''''' (COAST). On aurait pu s'attendre à ce que de tels caches soient en DRAM, vu qu'ils sont placés sur des barrettes de RAM, mais la ressemblance avec la mémoire RAM principale s'arrête là. Le cache était fabriqué en mémoire SRAM, même s'il est en théorie possible de faire de tels caches avec de la DRAM. Les premiers processeurs avec un cache faisaient ainsi, au début des années 90. Il a été introduiot sur les processeurs Motorola, et a été utilisé sur les IBM PC et les Macintosh de l'époque. Les ordinateurs Macintosh utilisaient de tels caches, pour la pluaprt des modèles. Pour ce qui est des PC, les premiers processeurs x86 faisaient pareil, notamment les processeurs Intel. Le 486, le Pentium et le Pentium 2 utilisaient des ''Cache on a stick''. L'avantage est que cela permettait de mettre plus de cache, à une époque où les circuits étaient limités en transistors. De plus, cela permettait au consommateur de choisir quelle quantité de cache il voulait, selon ses finances. Il était possible de laisser le processeur fonctionner soit sans mémoire cache, soit avec un cache de 256 Kibioctets, de 512 Kibioctets, etc. Il était possible d'upgrader le cache si besoin. Pour les CPU Intel, le cache était connecté sur le bus système, au même titre que la mémoire RAM et les entrées-sorties. Il faut dire que les processeurs de l'époque utilisaient un bus système et n'avaient pas de bus mémoire dédié. Mais en théorie, rien n’empêche de connecter le cache sur un bus mémoire dédié. Toujours est-il que les lectures et écritures étaient propagées à la fois dans le cache et la RAM. Les écritures se faisaient dans les deux, systématiquement dans la RAM, mais aussi dans le cache en cas de succès de cache. Les lectures étaient servies soit par le cache en cas de succès de cache, soit par la RAM en cas de défaut de cache. Si le cache répondait en premier, la transaction sur le bus se terminait précocement et l'accès en RAM était abandonné. [[File:Intel486 Иерархия памяти.png|centre|vignette|upright=2.5|Intel486 : le cache était connecté sur le bus système.]] À l'inverse, certains processeurs possédaient un cache fabriqué en mémoire DRAM, et plus précisément avec de la mémoire eDRAM. Le cache n'était pas intégré dans le même circuit imprimé que le processeur, mais profitait d'une architecture en ''chiplet''. Pour rappel, cela veut dire que le processeur est en réalité composé de plusieurs circuits intégré séparés, mais interconnectés et soudés sur un même PCB carré. Avec un cache en eDRAM, le cache avait son propre circuit intégré, séparé du circuit intégré du processeur ou du circuit intégré pour le contrôleur mémoire/IO. Un exemple est celui du cache des processeurs Intel de microarchitecture Broadwell, vus dans ce chapitre dans la section sur les caches splittés. Les tags étaient intégrés dans le circuit intégré du processeur, mais les données étaient mémorisées dans une puce d'eDRAM séparée. La puce eDRAM correspondait en réalité à une DRAM adressable qui servait de DRAM pour les données et mémorisaient les voies du cache. ==Les caches adressés par somme et hashés== Les caches adressés par somme sont optimisés pour incorporer certains calculs d'adresse directement dans le cache lui-même. Pour rappel, certains modes d'adressage impliquent un calcul d'adresse, qui ajoute une constante à une adresse de base. Généralement, l'adresse de base est l'adresse d'un tableau ou d'une structure, et la constante ajoutée indique la position de la donnée dans le tableau/la structure. Les caches hashés et les caches adressés par somme permettent de faire l'addition directement dans la mémoire cache. Voyons d'abord les caches hashés, avant de passer aux caches adressés par somme. Sur les '''caches hashés''', l'addition est remplacée par une autre opération, par exemple des opérations bit à bit du style XOR, AND ou OR, etc. Seulement, utiliser des opérations bit à bit pose un problème : il arrive que deux couples Adresse/décalage donnent le même résultat. Par exemple, le couple Adresse/décalage 11101111/0001 donnera la même adresse que le couple 11110000/0000. Dit autrement, deux adresses censées être différentes (après application du décalage) sont en réalité attribuées à la même ligne de cache. Il est toutefois possible de gérer ces situations, mais cela demande des astuces de haute volée pour faire fonctionner la mémoire cache correctement. Sur les '''caches adressés par somme''', le décodeur est modifié pour se passer de l'addition. Pour comprendre comment, il faut rappeler qu'un décodeur normal est composé de comparateurs, qui vérifient si l'entrée est égale à une constante bien précise. Sur un cache ordinaire, l'addition est faite séparément du décodage des adresses par le cache, dans l'unité de calcul ou dans l'unité de génération d'adresse. [[File:Non sum adressed cache.png|centre|vignette|upright=2|Cache normal.]] Mais les caches adressés par somme modifient le décodeur, qui est alors composé de comparateurs qui testent si la somme adresse + décalage est égale à une constante. [[File:Cache adressé par somme.png|centre|vignette|upright=2|Cache adressé par somme.]] Chaque circuit du décodeur fait le test suivant, avec K une constante qui dépend du circuit : : <math>A + B = K</math> Ce qui est équivalent à faire le test suivant : : <math>A + B - K = 0</math> En complément à deux, on a <math>- K = \overline{K} + 1</math>. En injectant dans l'équation précédente, on a : : <math>A + B + \overline{K} + 1 = 0</math> En réorganisant les termes, on a : : <math>A + B + \overline{K} = - 1</math> Il suffit d'utiliser un additionneur ''carry-save'' pour faire l'addition des trois termes. Rappelons qu'un tel additionneur fournit deux résultats en sortie : une somme calculée sans propager les retenues et les retenues en question. Notons que les retenues sont à décaler d'un cran, vu qu'elles sont censées s'appliquer à la colonne suivante. En notant la somme S et les retenues R, on a: : <math>S + (R << 1) = - 1 </math>, le décalage d'un cran à gauche étant noté <math><< 1</math>. Ensuite, -1 est codé avec un nombre dont tous les bits sont à 1 en complément à un/deux. : <math>S + (R << 1) = 111 \cdots 111111</math> [[File:Sum + retenue add.png|centre|vignette|upright=2|Sum + retenue add]] Un simple raisonnement nous permet de savoir si le résultat est bien -1, sans faire l'addition <math>S + (R << 1)</math>. En effet, on ne peut obtenir -1 que si la somme est l'inverse des retenues : un 0 dans le premier nombre correspond à un 1 dans l'autre, et réciproquement. En clair, on doit avoir <math>\overline{S} = R << 1</math>. Pour vérifier cela, il suffit de faire un simple XOR entre la somme et les retenues décalées d'un cran. On a alors : : <math>S \oplus (R << 1) = 111 \cdots 111111</math> La comparaison avec -1 se fait avec une porte ET à plusieurs entrées. En effet, la porte donnera un 1 seulement si tous les bits d'entrée sont à 1, ce qui est ce qu'on veut tester. Au final, l'additionneur pour l'addition adresse + décalage est remplacé par un additionneur carry-save suivi d'une couche de portes XOR et d'un comparateur avec une constante, ce qui économise de circuits et améliore les performances. [[File:Final circuit of sum addressed cache.png|centre|vignette|upright=2|Cache adressé par somme.]] En prenant en compte que la constante K est justement une constante, certaines entrées de l'additionneur carry-save sont toujours à 0 ou à 1, ce qui permet quelques simplifications à grand coup d’algèbre de Boole. Chaque additionneur complet qui compose l’additionneur carry-save est remplacée par des demi-additionneurs (ou par un circuit similaire). Autant dire que l'on gagne tout de même un petit peu en rapidité, en supprimant une couche de portes logiques. Le circuit de décodage économise aussi des portes logiques, ce qui est appréciable. ==Les caches à accès uniforme et non-uniforme== Intuitivement, le temps d'accès au cache est le même pour toutes les lignes de cache. Il s'agit de cache appelés '''caches à accès uniforme''', sous-entendu à temps d'accès uniforme. Mais sur les caches de grande capacité, il arrive souvent que le temps de propagation des signaux varie fortement suivant la ligne de cache à lire. D'ordinaire, on se cale sur la ligne de cache la plus lente pour caler la fréquence d'horloge du cache, même si on pourrait faire mieux. Cependant, les '''caches à accès non uniforme''' ont une latence différente pour chaque ligne d'un même cache. Certaines lignes de cache sont plus rapides que d'autres. Niveau terminologie, nous allons parler de caches UCA et NUCA : ''Uniform Access Cache'' pour les caches à accès uniforme, ''Non-Uniform Access Cache'' pour les caches à accès non-uniforme. [[File:Caches UCA et NUCA.png|vignette|Caches UCA et NUCA.]] Les caches NUCA et UCA sont souvent composés de plusieurs banques séparées, typiquement une par voie. Sur les caches UCA, les banques sont interconnectées avec le processeur de manière à ce que toutes les interconnexions ont la même longueur pour toutes les banques. Typiquement, les banques sont organisées en carré, avec les interconnexions qui partent du centre, avec une disposition en H, illustrée ci-contre Mais avec les caches NUCA, ce n'est pas le cas. Les interconnexions sont simplifiées et ont des longueurs différentes. Les caches NUCA n'ont pas tous le même genre d'interconnexions, qui dépendent du cache NUCA. En général, les interconnexion forme un réseau avec des sortes de routeurs qui redirigent les données/commandes vers la bonne destination : cache ou processeur. Les banques plus proches du processeur sont accessibles plus rapidement que celles éloignées, même si la différence n'est pas énorme. Les caches NUCA sont généralement associatifs par voie. Les plus simples utilisent une banque par voie pour le cache, ce qui fait que certaines voies répondent plus vite que les autres. La détection des succès de cache est alors plus rapide si la donnée lue/écrite est dans une voie/banque rapide. En théorie, les défauts de cache demandent de vérifier toutes les banques, et se calent donc sur la pire latence. Mais divers caches se débrouillent pour que ce ne soit pas le cas, soit en vérifiant les banquyes unes par une, soit par un mécanisme de recherche plus complexe. Les caches NUCA sont surtout utilisés pour les caches L3 et L4, éventuellement les caches L2. Les caches L1 sont systématiquement des caches UCA, car la latence de l'accès au cache L1 est utilisée par le processeur pour décider quand lancer les instructions. Pour simplifier, le processeur peut démarrer en avance une instruction avant qu'une opérande soit lue dans le cache L1, de manière à ce que la donnée arrive en entrée de l'ALU pile en même temps que l'instruction. Une histoire d'exécution dans le désordre et d'émission anticipée des instructions qu'on détaillera dans une bonne dizaine de chapitres. Toujours est-il que tout est plus simple pour le processeur si le cache L1 a un temps d'accès fixe. Par contre, les caches L3 et L4 sont traités en attendant que les données arrivent, le processeur reprend l'exécution des instructions quand les caches L3 et L4 ont terminé de répondre, pas avant. Avec l'association une banque = une voie, la correspondance ligne de cache → bloc de mémoire qui est statique : on ne peut pas déplacer le contenu d'une ligne de cache dans une autre portion de mémoire plus rapide suivant les besoins. Mais la recherche académique a étudié le cas où la correspondance entre une ligne de cache et une banque varie à l’exécution. Pour nommer cette distinction, on parle de caches S-NUCA (''Static NUCA'') et D-NUCA (''Dynamic NUCA''). Intuitivement, on s'attend à ce que les caches D-NUCA soient plus performants que les caches S-NUCA. Les lignes de cache les plus utilisées peuvent migrer dans une banque rapide, alors que les lignes de cache moins utilisées vont dans une banque éloignée. Les lignes de cache se répartissent dans le cache dynamiquement dans les banques où elles sont le plus adaptées. Mais paradoxalement, le gain des caches D-NUCA est presque nul, voire insignifiant. La raison est que les caches D-NUCA doivent incorporer un système pour déterminer dans quelle banque se situe la donnée pour détecter les succès/défauts de cache, ainsi qu'un système pour migrer les données entre banques. Et ce système augmente le temps d'accès au cache, réduisant à néant l'intérêt d'un cache D-NUCA. Si on économise quelques microsecondes de temps d'accès en passant d'un cache UCA à un cache S-NUCA, ce n'est pas pour les perdre en passant à un D-NUCA. La majorité des caches D-NUCA sont donc en cours de recherche, mais ne sont pas utilisés en pratique. ==La tolérance aux erreurs des caches== Une mémoire cache reste avant tout une mémoire RAM, bien que ce soit de la SRAM. Elle n'est pas parfaite et est donc sujette à des erreurs, qui peuvent inverser un bit ou l'effacer. De telles erreurs sont liées à des rayons cosmiques très énergétiques, à des particules alpha produites par le packaging ou le métal deu circuit intégré, peu importe : l'essentiel est qu'ils inversent parfois un bit. Les mémoires modernes savent se protéger contre de telles erreurs, en utilisant trois moyens. ===Les mémoires caches ECC et à bit de parité=== Le premier moyen est l'usage de codes correcteurs d'erreurs, qui ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Les bits ajoutés dépendent de la donnée mémorisée dans le byte, et servent à détecter une erreur, éventuellement à la corriger. Le cas le plus simple ajoute un simple bit de parité pour chaque byte et se contente de détecter les erreurs dans les corriger. Les autres codes ECC permettent eux de corriger des erreurs, mais ils demandent d'ajouter au moins deux bits par byte, ce qui a un cout en circuit plus élevé. Un simple bit de parité permet de détecter qu'un bit a été inversé, mais ne permet pas de corriger l'erreur. En soi, ce n'est pas un problème. Si une erreur est détectée, on considère que la ligne de cache est invalide. Le cache gère la situation comme un défaut de cache et va chercher la donnée valide en mémoire RAM. Le cout en circuits est donc faible, mais les défauts de cache sont plus nombreux. Les codes ECC sont eux capables de corriger les erreurs, si elles ne modifient pas trop de bits d'un coup. Par contre, ils utilisent deux à trois bits par octet, ce qui a un cout en circuits loin d'être négligeable. Il y a donc un compromis entre défauts de cache et cout en circuits. La gestion de l'ECC est différente suivant le niveau de cache. Généralement, le cache L1 n'utilise pas l'ECC mais se contente d'un simple bit de parité pour éviter la corruption de ses données. Le cache étant petit, les corruptions de données sont assez rares, et les défauts de cache induits faibles. Il est plus important d'utiliser un code de détection d'erreur simple, rapide, qui ne ralentit pas le cache et n'augmente pas sa latence. Si une ligne de cache est corrompue, il a juste à aller lire la ligne depuis le cache L2, ou un niveau de cache inférieur. Du moins, c'est possible sur le cache en question est un cache inclusif et/ou ''write-through''. Par contre, le niveau de cache L2 et ceux en-dessous utilisent presque systématiquement une mémoire SRAM ECC. La raison principale étant que ce sont des caches assez gros, pour lesquels la probabilité d'une erreur est assez élevée. Plus une mémoire a de bits et prend de la place, plus il y a une chance élevée qu'un bit s'inverse. Et vu que les caches L2/L3/L4 sont par nature plus lents et plus gros, ils peuvent se permettre le cout en performance lié à l'ECC, idem pour le cout en circuit. Sans compter qu'en cas d'erreur, ils doivent aller lire la ligne de cache originelle en mémoire RAM, ce qui est très lent ! Mieux vaut corriger l'erreur sur place en utilisant l'ECC. ===L'usage du ''memory scrubbing'' sur les caches=== La plupart des erreurs ne changent qu'un seul bit dans un byte, mais le problème est que ces erreurs s'accumulent. Entre deux accès à une ligne de cache, il se peut que plusieurs erreurs se soient accumulées, ce qui dépasse les capacités de correction de l'ECC. Dans ce cas, il existe une solution appelée le ''memory scrubbing'', qui permet de résoudre le problème au prix d'un certain cout en performance. Pour rappel, l'idée est de vérifier les lignes de caches régulièrement, pour éviter que les erreurs s'accumulent. Par exemple, on peut vérifier chaque ligne de cache toutes les N millisecondes, et corriger une éventuelle erreur lors de cette vérification. En faisant des vérifications régulières, on garantir que les erreurs n'ont pas le temps de s'accumuler, sauf en cas de malchance avec des erreurs très proches dans le temps. Il ne s'agit pas d'un rafraichissement mémoire, car les SRAM ne s'effacent pas), mais ça a un effet similaire. Et évidemment, le ''memory scrubbing'' a un cout en performance. On peut faire une comparaison avec le rafraichissement mémoire : les rafraichissement réguliers réduisent les performances, car cela fait des accès en plus. Des accès qui sont de plus timés à des instants bien précis qui ne sont pas forcément les plus adéquats. Il est possible qu'un rafraichissement ait lieu en même temps qu'un accès mémoire et le rafraichissement a la priorité, ce qui réduit les performances. La même chose arrive avec les vérifications du ''memory scrubbing''. Malgré tout, la technique a été utilisée sur les caches de certains processeurs commerciaux, dont des processeurs AMD Athlon et Athlon 64. Elle est surtout utilisable sur les caches L2/L3, pour lesquels le cout du pseudo-rafraichissement est acceptable. ==Un exemple de cache : le cache d'instruction== La grande majorité des processeurs utilise deux caches L1 séparés : un '''cache d'instructions''' dédié aux instructions, et un autre pour les données. Une telle organisation permet de charger une instruction tout en lisant une donnée en même temps. Notons que seul le cache L1 est ainsi séparé entre cache de données et d'instructions. Le cache d’instruction se situe en théorie entre l'unité de chargement et l'unité de décodage. En effet, ce cache prend en entrée une adresse et fournit une instruction. L'adresse est fournie par le ''program counter'', l'instruction est envoyée dans l'unité de décodage. Le cache se situe donc entre les deux. Le cache de données L1 est connecté au chemin de données, et notamment aux unités de communication avec la mémoire, pas au séquenceur. [[File:Caches L1 et positions dans le processeur.png|centre|vignette|upright=2.5|Caches L1 et positions dans le processeur]] Les deux caches sont reliés au processeur par des bus séparés, l'ensemble ressemble à une architecture Harvard, mais où les caches remplacent les mémoires RAM/ROM. Le cache d'instruction prend la place de la mémoire ROM et le cache de données prend la place de la mémoire RAM. Évidemment, il y a des niveaux de caches en dessous des caches de données/instruction, et ceux-ci contiennent à la fois données et instructions, les deux ne sont pas séparées dans des mémoires/caches séparés. Raison pour laquelle l'ensemble est appelé une '''architecture Harvard modifiée'''. Architecture Harvard, car l'accès aux données et instructions se font par des voies séparées pour le processeur, modifiée car la séparation n'est effective que pour le cache L1 et pas les autres niveaux de cache, et encore moins la RAM. Sur les processeurs modernes, il arrive très souvent que le processeur doive charger une instruction et lire/écrire une donnée en même temps. Et à vrai dire, c'est la règle plus que l'exception. L'usage d'une architecture Harvard modifiée permet cela très facilement : on peut accéder au cache d'instruction via un bus, et au cache de donnée avec l'autre ===Pourquoi scinder le cache L1 en cache d'instruction et de données=== L'usage d'un cache d’instruction séparé du cache de données est à contraster avec l'usage d'un cache L1 multiport unique, capable de mémoriser à la fois instructions et données. Les deux solutions sont possibles ont été utilisées. Les premiers processeurs avaient un cache L1 unique et multiport, mais ce n'est plus le cas sur les processeurs modernes, car les contraintes ne sont pas les mêmes. Le compromis à faire est celui entre deux petits caches rapides et un gros cache plus lent. Pour rappel, plus un cache est petit, plus il est rapide et chauffe moins. Donc au lieu d'utiliser, par exemple, un gros cache lent de 64 Kibioctets, on utilise deux caches de 32 kibioctets, plus rapides. La capacité totale est la même, mais le temps d'accès plus faible. En termes de temps d'accès, la meilleure solution est celle des deux caches simple port. Mais pour ce qui est de l'économie de circuits, c'est moins évident. Entre deux mémoires simple port et une mémoire multiport, la différence en termes de transistors est ambigüe et dépend de la capacité des caches. La différence est surtout notable pour les gros caches, moins pour les petits caches. Il faut aussi tenir compte de la capacité effective. Avec deux caches séparés, la répartition de la capacité du cache L1 est fixée une bonne fois pour toutes. Par exemple, avec un cache d'instruction de 32 KB et un cache de données de 32 KB, impossible d'allouer 40 KB aux données et 20 aux instructions. Alors qu'avec un cache L1 unique de 64 KB, on pourrait le faire sans soucis. La répartition se fait naturellement, en fonction de la politique de remplacement du cache et est proche de l'optimal. C'est là un désavantage des caches d'instructions/données séparés : une capacité effective moindre. Tout cela explique pourquoi le cache L1 est le seul à être ainsi scindé en deux, avec une séparation entre instructions et données : les contraintes au niveau du cache L1 et L2 ne sont pas les mêmes. Pour les caches L1, le temps d'accès est plus important que la capacité, ce qui favorise les caches séparés. Par contre, pour les caches L2/L3/L4, le temps d'accès n'est pas déterminant, alors que la capacité effective et l'économie en circuits sont significatives. ===La connexion des caches L1 avec le cache L2=== Pour les connexions avec le cache L2, tout dépend du processeur. Certains utilisent un cache L2 multiport, qui permet aux deux caches L1 de lire ou écrire dans le cache L2 simultanément. [[File:Cache d'instructions.png|centre|vignette|upright=1.5|Cache d'instructions.]] Si le cache L2 ne gère pas les accès simultanés, il n'y a qu'un seul bus relié aux caches L1 et au cache L2. On doit effectuer un arbitrage pour décider quel cache a la priorité, chose qui est réalisé par un circuit d'arbitrage spécialisé. [[File:Circuit d'arbitrage du cache.png|centre|vignette|upright=1.5|Circuit d'arbitrage du cache.]] Généralement, les caches d'instructions peuvent se permettre d'être plus petits que les caches de données, car les programmes sont souvent plus petits que les données manipulées. Songez que des programmes de quelques mébioctets peuvent parfois remplir la RAM avec plusieurs gibioctets de données. Lancez votre navigateur internet et ouvrez une page web un peu chargée, pour vous en convaincre ! ===Les spécificités du cache d'instruction : lecture seule, bloquant, etc=== Les instructions sont rarement modifiées ou accédées en écritures, contrairement aux données. Et cela permet d'utiliser un cache simplifié pour les instructions. Autant un cache généraliste doit permettre les lectures et écritures depuis le processeur (avec les échanges avec la RAM), autant un cache d'instruction peut se contenter des lectures provenant du CPU et des échanges avec la RAM. Le cache d'instructions est donc très souvent en « lecture seule » : le processeur ne peut pas écrire dedans, mais juste le lire ou charger des instructions dedans. Un cache d'instruction est donc plus simple qu'un cache pour les données : on peut retirer les circuits en charge de l'écriture (mais on doit laisser un port d'écriture pour charger les instructions dedans). Le gain en circuits permet d'utiliser un cache d'instruction plus gros ou au contraire de laisser de la place pour le cache de données. Le gain en termes de capacité compense alors un peu les inconvénients des caches séparés. Par contre, cela complique la gestion du code automodifiant, c'est-à-dire des programmes dont certaines instructions vont aller en modifier d'autres, ce qui sert pour faire de l'optimisation ou est utilisé pour compresser ou cacher un programme (les virus informatiques utilisent beaucoup de genre de procédés). Quand le processeur exécute ce genre de code, il ne peut pas écrire dans ce cache L1 d'instructions, mais doit écrire dans le cache L2 ou en RAM, avant de recharger les instructions modifiées dans le cache L1. Cela qui prend du temps et peut parfois donner lieu à des erreurs si le cache L1 n'est pas mis à jour. Les algorithmes de remplacement des lignes de cache optimaux pour les données ne le sont pas pour les instructions, de même que la taille optimale du cache, la taille des lignes de cache optimale, ou même les algorithmes de préchargement. Par exemple, pour le remplacement des lignes de cache, un simple algorithme LRU est presque optimal pour les instructions, autant il peut donner de mauvaises performances quand on manipule beaucoup de tableaux. Cela justifie d'utiliser des caches spécialisés pour chacune. On peut adapter le cache d'instruction à son contenu, ce qui le rend plus rapide ou plus petit à performance égale. Les caches d'instructions sont généralement des caches bloquants. Il ne servirait à rien de rendre un cache d'instruction non-bloquant, le cout en circuits ne se traduirait pas par une augmentation significative des performances. À l'opposé, les caches de données sont non-bloquants sur les architectures modernes, pour des raisons de performance. Ce qui rend la séparation assez intéressante, les deux caches ayant des besoins différents et des implémentations différentes, cela permet d'optimiser le cout en transistors des caches. ===L'impact du cache d'instruction sur les performances=== Sur les architectures conventionnelles, le cache d'instruction a plus d'impact sur les performances que le cache de données. La raison principale est que les instructions ont une meilleure localité spatiale et temporelle que pour les données. Pour la localité spatiale, les instructions consécutives se suivent en mémoire, alors que rien ne garantit que des données utilisées ensemble soient regroupées en mémoire. Pour localité temporelle, elle est très variable pour les données, mais très courante pour les instructions du fait de l'usage fréquent des boucles et des fonctions. : La présence de branchements atténue la localité temporelle des instruction, sauf que la majorité des branchements sautent à un endroit très proche, seuls les appels de fonction brisent la localité spatiale. La conséquence est qu'il arrive que certains CPU aient un cache L1 d'instruction plus gros que celui pour les données. On parle alors de '''cache L1 asymétriques'''. Un exemple est celui des processeurs AMD de microarchitecture Zen, dont le cache d'instruction était deux fois plus gros que le cache de données. Leur cache d'instruction faisait 64 kibioctets, contre seulement 32 pour le cache de données. D'ailleurs, il existe des processeurs assez extrêmes qui se contentent d'un cache d'instruction unique, sans cache de données. C'est le cas sur les processeurs vectoriels ou les GPU que nous verrons dans les chapitres de fin de ce wikilivres. De tels processeurs sont spécialisés dans la manipulation de tableaux de données, traitement qui a une faible localité temporelle. En conséquence, utiliser un cache de données n'est pas vraiment utile, voire peu être contreproductif, alors qu'un cache d’instruction fonctionne parfaitement. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Compléments sur les mémoires de masse | prevText=Compléments sur les mémoires de masse | next=Le préchargement | nextText=Le préchargement }} </noinclude> 731dzqxxsd0vx008l0xr7l4pfjpsoss 765119 765118 2026-04-26T15:22:14Z Mewtow 31375 /* L'exemple des processeurs 386 et du contrôleur de cache 82385 */ 765119 wikitext text/x-wiki Le cache est une mémoire intercalée entre la mémoire et un processeur, plus rarement à l'intérieur d'un périphérique. Il est souvent fabriquée avec de la mémoire SRAM, parfois avec de l'eDRAM. Sans lui, on se croirait à l'âge de pierre tellement nos PC seraient lents ! En effet, la mémoire est très lente comparée au processeur. Le temps mis pour accéder à la mémoire est du temps durant lequel le processeur n'exécute pas d'instruction (sauf cas particuliers impliquant un pipeline). Pour diminuer ce temps d'attente, il a été décidé d'intercaler une mémoire petite mais rapide, entre le processeur et la mémoire. Ainsi, le processeur accède à un cache très rapide plutôt qu'à une RAM beaucoup plus lente. ==L'accès au cache== Le cache contient une copie de certaines données présentes en RAM. La copie présente dans le cache est accessible bien plus rapidement que celle en RAM, vu que le cache est plus rapide. Mais seule une petite partie de ces données sont copiées dans le cache, les autres données devant être lues ou écrites dans la RAM. Toujours est-il que le cache contient une copie des dernières données accédées par le processeur. Une donnée est copiée dans la mémoire cache quand elle est lue ou écrite par le processeur. Le processeur conserve une copie de la donnée dans le cache après son premier accès. Les lectures/écritures suivantes se feront alors directement dans le cache. Évidemment, au fur et à mesure des accès, certaines données anciennes sont éliminées du cache pour faire de la place aux nouveaux entrants, comme nous le verrons plus tard. [[File:Principe d'une mémoire cache.gif|centre|vignette|upright=2|Principe d'une mémoire cache.]] La mémoire cache est invisible pour le programmeur, qui ne peut pas déceler celles-ci dans l'assembleur. Les accès mémoire se font de la même manière avec ou sans le cache. La raison à cela est que le cache intercepte les accès mémoire et y répond s'il en a la capacité. Par exemple, si le cache intercepte une lecture à une adresse et que le contenu de cette adresse est dans le cache, le cache va outrepasser la mémoire RAM et la donnée sera envoyée par le cache au lieu d'être lue en RAM. par contre, si un accès se fait à une adresse pour laquelle le cache n'a pas la donnée, alors l'accès mémoire sera effectué par la RAM de la même manière que si le cache n'était pas là. [[File:Accès au cache.png|centre|vignette|upright=2|Accès au cache]] ===Les succès et défauts de caches=== Tout accès mémoire est intercepté par le cache, qui vérifie si la donnée demandée est présente ou non dans le cache. Si la donnée voulue est présente dans le cache, on a un '''succès de cache''' (''cache hit'') et on accède à la donnée depuis le cache. Sinon, c'est un '''défaut de cache''' (''cache miss'') et on est obligé d’accéder à la RAM. Les défauts de cache peuvent avoir plusieurs origines. Tout ce qu'il faut savoir est que lorsque le processeur accède à une donnée ou une instruction pour la première fois, il la place dans la mémoire cache car elle a de bonnes chances d'être réutilisée prochainement. La raison à cela est qu'un programme a tendance à réutiliser les instructions et données qui ont été accédées dans le passé : c'est le ''principe de localité temporelle''. Bien évidement, cela dépend du programme, de la façon dont celui-ci est programmé et accède à ses données et du traitement qu'il fait, mais c'est souvent vrai en général. La première cause des défauts de cache est liée à la taille du cache. À force de charger des données/instructions dans le cache, le cache fini par être trop petit pour conserver les anciennes données. Le cache doit bien finir par faire de la place en supprimant les anciennes données, qui ont peu de chances d'être réutilisées. Ces anciennes données éliminées du cache peuvent cependant être accédées plus tard. Tout prochain accès à cette donnée mènera à un cache miss. C'est ce qu'on appelle un ''Capacity Cache Miss'', ou encore '''défaut de capacité'''. Les seules solutions pour éviter cela consistent à augmenter la taille du cache ou à optimiser le programme exécuté (voir plus bas). Une autre raison pour un défaut est donc la suivante. Lorsqu'on exécute à une instruction ou qu'on accède à donnée pour la première fois, celle-ci n'a pas encore été chargée dans le cache. Le défaut de cache est inévitable : ce genre de cache miss s'appelle un ''Cold Miss'', ou encore un '''défaut à froid'''. De tels défauts sont presque impossibles à éliminer, sauf à utiliser des techniques de préchargement qui chargent à l'avance des données potentiellement utiles. Ces méthodes de préchargement se basent sur le principe de localité spatiale, à savoir le fait que les programmes ont tendance à accéder à des données proches en mémoire. Pour donner un exemple, les instructions d'un programme sont placées en mémoire dans l’ordre dans lequel on les exécute : la prochaine instruction à exécuter est souvent placée juste après l'instruction en cours (sauf avec les branchements). Quand on accède à une donnée ou une instruction, le cache peut précharger les données adjacentes pour en profiter. Nous parlerons de ces techniques de préchargement dans un chapitre dédié, vers la fin du cours. ===Le fonctionnement du cache, vu du processeur=== Vu du processeur, le cache prend en entrée toutes les informations nécessaires pour effectuer un accès mémoire : des signaux de commande, une adresse et la donnée à écrire si besoin. Tout cela est passé en entrée du cache, celui-ci répondant aux accès mémoire via divers bits de contrôles, que le processeur peut lire à souhait. Le cache fournit aussi la donnée à lire, pour les lectures, sur une sortie, connectée directement au bus mémoire/processeur. Globalement, le cache a une capacité limitée, mais il prend en entrée des adresses complètes. Par exemple, sur un processeur 64 bits, le cache prend en entrée des adresses de 64 bits (sauf si optimisations), même si le cache en question ne fait que quelques mébioctets. Les caches sont souvent des mémoires multiports, surtout sur les processeurs récents. Les caches simple port sont rares, mêmes s'ils existent et ont existé par le passé. les caches double port sont eux plus fréquents, et ont généralement un port d'écriture séparé du port de lecture. Mais les caches récents ont plusieurs ports de lecture/écriture et sont capables de gérer plusieurs accès mémoire simultanés. Les données présentes dans le cache sont (pré)chargées depuis la mémoire, ce qui fait que toute donnée dans le cache est la copie d'une donnée en mémoire RAM. Le cache doit faire la correspondance entre une donnée du cache et l'adresse mémoire correspondante. Du point de vue du fonctionnement, on peut voir le cache comme une sorte de table de correspondance, qui mémorise des données, chacune étant associée à son adresse mémoire. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Cela vaut du point de vue du processeur, le fonctionnement interne du cache étant quelque peu différent selon le cache. Il existe des caches dont le fonctionnement interne est bien celui d'une table de correspondance matérielle, d'autres qui sont beaucoup plus optimisés. [[File:Fonctionnement d'une mémoire associative à correspondance.png|centre|vignette|upright=2|Fonctionnement simplifié d'une mémoire cache : les adresses sont dans la colonne de gauche, les données sont dans la colonne de droite. On voit qu'on envoie l'adresse au cache, que celui-ci répond en renvoyant la donnée associée.]] ==La performance des mémoires caches== L'analyse de la performance des mémoires caches est plus riche pour celle des autres mémoires. Sa performance dépend de beaucoup de paramètres, mais on peut cependant citer les principaux. Les deux premiers sont tout bonnement sa latence et son débit, comme pour n'importe quelle autre mémoire. La latence est plus importante que son débit, car le processeur est généralement plus rapide que le cache et qu'il n'aime pas attendre. Mais le critère le plus important pour un cache est sa capacité à empêcher des accès mémoire, son efficacité. Plus les accès mémoire sont servis par le cache au lieu de la RAM, meilleures seront les performances. Pour résumer, la performance d'un cache est surtout caractérisée par deux métriques : le taux de défaut, qui correspond à l’efficacité du cache, et la latence du cache. ===Le taux de succès/défaut=== Le '''taux de succès''' (hit ratio) est un premier indicateur des performances du cache, mais un indicateur assez imparfait. C'est le pourcentage d'accès mémoire qui ne déclenchent pas de défaut de cache. Plus il est élevé, plus le processeur accède au cache à la place de la RAM et plus le cache est efficace. Certains chercheurs préfèrent utiliser le '''taux de défauts''', à savoir le pourcentage d'accès mémoire qui entraînent un défaut de cache. Plus il est bas, meilleures sont les performances. Le taux de défaut est relié au taux de succès par l'équation <math>T_\text{succes} = 1 - T_\text{defaut}</math>. Par définition, il est égal à : : <math>\text{Taux de défauts de cache} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d’accès mémoires}}</math> Plutôt que de comparer le nombre de défauts/succès de cache au nombre d'accès mémoire, il est aussi possible de diviser le nombre de défauts par le nombre total d'instructions. On obtient alors le '''taux de défauts/succès par instruction''', une autre métrique utile. Par définition, elle est égale à : : <math>\text{Taux de défauts par instruction} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d'instructions}} = \text{Taux de défauts de cache} \times \frac{\text{Nombre d’accès mémoires}}{\text{Nombre d'instructions}}</math> Si certains défauts de cache sont inévitables quel que soit le cache, comme les défauts à froids, mentionnés plus haut, d'autres défauts peuvent être évités en augmentant la capacité du cache. C'est le cas des défauts de capacité qui sont causés par un accès à une donnée qui a été éliminée du cache faute de place. Plus le cache est gros, moins il a de chances d'être rempli, moins il doit rapatrier de données, plus son taux de succès augmente. Mais nous reviendrons sur le lien entre taille du cache et taux de défaut plus bas. Le taux de succès ne dépend pas que du cache, mais aussi de la conception des programmes exécutés. Une bonne utilisation du cache (ainsi que de la mémoire virtuelle) repose sur le programmeur qui doit prendre en compte les principes de localités dès la conception de ses programmes. Par exemple, un programmeur peut parfaitement tenir compte du cache au niveau de son algorithme : on peut citer l'existence des algorithmes ''cache oblivious'', qui sont conçus pour être optimaux quelle que soit la taille du cache. Le programmeur peut aussi choisir ses structures de données de manière à améliorer la localité. Par exemple, un tableau est une structure de donnée respectant le principe de localité spatiale, tandis qu'une liste chaînée ou un arbre n'en sont pas (bien qu'on puisse les implémenter de façon à limiter la casse). D'autres optimisations sont parfois possibles : par exemple, le sens de parcours d'un tableau multidimensionnel peut faire une grosse différence. Cela permet des gains très intéressants pouvant se mesurer avec des nombres à deux ou trois chiffres. Je vous recommande, si vous êtes programmeur, de vous renseigner le plus possible sur les optimisations de code ou algorithmiques qui concernent le cache : il vous suffira de chercher sur Google. Il y a une citation qui résume bien cela, prononcée par un certain Terje Mathisen. Si vous ne le connaissez pas, cet homme est un vieux programmeur (du temps durant lequel on codait encore en assembleur), grand gourou de l’optimisation, qui a notamment travaillé sur le moteur de Quake 3 Arena. {{BlocCitation|Almost all programming can be viewed as an exercise in caching.|auteur=Terje Mathisen}} ===La latence moyenne d'un cache=== Le temps mis pour lire ou écrire une donnée varie en présence d'un cache. Certaines lectures/écritures vont atterrir directement dans le cache (succès) tandis que d'autres devront aller chercher leur contenu en mémoire RAM (défaut de cache). Dans tous les cas, qu'il y ait défaut ou non, le cache sera consulté et mettra un certain temps à répondre, égal au temps de latence du cache. Tous les accès mémoires auront donc une durée au moins égale au temps de latence du cache, qui sera notée <math>T_c</math>. En cas de succès, le cache aura effectué la lecture ou l'écriture, et aucune action supplémentaire n'est requise. Ce qui n'est pas le cas en cas de défaut : le processeur devra aller lire/écrire la donnée en RAM, ce qui prend un temps supplémentaire égal au temps de latence de la mémoire RAM. Un défaut ajoute donc un temps, une pénalité, à l'accès mémoire. Dans ce qui suivra, le temps d'accès à la RAM sera noté <math>T_m</math>. Fort de ces informations, nous pouvons calculer le temps de latence moyen d'un accès mémoire, qui est la somme du temps d'accès au cache (pour tous les accès mémoire), multiplié par le temps lié aux défauts. On a alors : : <math>T = T_c + \text{Taux de défaut} \times T_m</math> On voit que plus le taux de succès est élevé, plus le temps de latence moyen sera bas, et inversement. Ce qui explique l'influence du taux de succès sur les performances du cache, influence assez importante sur les processeurs actuels. De nos jours, le temps que passe le processeur dans les défauts de cache devient de plus en plus un problème au fil du temps, et gérer correctement le cache est une nécessité, particulièrement sur les processeurs multi-cœurs. Il faut dire que la différence de vitesse entre processeur et mémoire est tellement importante que les défauts de cache sont très lents : alors qu'un succès de cache va prendre entre 1 et 5 cycles d'horloge, un cache miss fera plus dans les 400-1000 cycles d'horloge. Tout ce temps sera du temps de perdu que le processeur aura du mal à mitiger. Autant dire que réduire les défauts de cache est beaucoup plus efficace que d'optimiser les calculs effectués par le processeur (erreur courante chez de nombreux programmeurs, notamment débutants). ===L'impact de la taille du cache sur le taux de défaut et la latence=== Il y a un lien entre taille du cache, taux de défaut, débit binaire et latence moyenne. Globalement, plus un cache est gros, plus il est lent. Simple application de la notion de hiérarchie mémoire vue il y a quelques chapitres. Les raisons à cela sont nombreuses, mais nous ne pouvons pas les aborder ici, car il faudrait que nous sachions comment fonctionne un cache et ce qu'il y a à l'intérieur, ce qui sera vu dans la suite du chapitre. Toujours est-il que la latence moyenne d'un cache assez gros est assez importante. De même, le débit binaire d'un cache diminue avec sa taille, mais dans une moindre mesure. Les petits caches ont donc un gros débit binaire et une faible latence, alors que c'est l'inverse pour les gros caches. Une grande capacité de cache améliore le taux de succès, mais cela se fait au détriment de son temps de latence et de son débit, ce qui fait qu'il y a un compromis assez difficile à trouver entre taille du cache, latence et débit. Il peut arriver qu'augmenter la taille du cache augmente son temps d'accès au point d’entraîner une baisse de performance. Par exemple, les processeurs Nehalem d'Intel ont vus leurs performances dans certains jeux vidéos baisser de 2 à 3 %, malgré de nombreuses améliorations architecturales, parce que la latence du cache L1 avait augmentée de 2 cycles d'horloge. Pour avoir une petite idée du compromis à faire, regardons la relation entre taille du cache et taux de défaut. Il existe une relation approximative entre ces deux variables, appelée la '''loi de puissance des défauts de cache'''. Elle donne le nombre total de défaut de cache en fonction de la taille du cache et de deux autres paramètres. Voici cette loi : : <math>\text{Taux de défauts de cache} \approx K \times \text{Taille du cache}^{- \alpha }</math>, avec <math>K</math> et <math>\alpha</math> deux coefficients qui dépendent du programme exécuté. Le coefficient <math>\alpha</math> est généralement compris entre 0.3 et 0.7, guère plus, et varie suivant le programme exécuté. Précisons que cette loi ne marche que si le cache est assez petit par rapport aux données à utiliser. Pour un cache assez gros et des données très petites, la relation précédente est mise en défaut. Pour s'en rendre compte, il suffit d'étudier le cas extrême où toutes les données nécessaires tiennent dans le cache. Dans ce cas, il n'y a qu'un nombre fixe de défauts de cache : autant qu'il faut charger de données dans le cache. Le nombre de défauts de cache observé dans cette situation n'est autre que le coefficient <math>K</math> de la situation précédente, mais il n'y a aucune dépendance entre taux de défaut et taille du cache. L'origine de cette relation s'explique quand on regarde combien de fois chaque donnée est réutilisée lors de l’exécution d'un programme. La plupart des données finissent par être ré-accédées à un moment ou un autre et il se passe un certain temps entre deux accès à une même donnée. Sur la plupart des programmes, les observations montrent que beaucoup de réutilisations de données se font après un temps très court et qu'inversement, peu de ré-accès se font après un temps inter-accès long. Si on compte le nombre de réutilisation qui ont un temps inter-accès bien précis, on retrouve une loi de puissance identique à celle vue précédemment : : <math>\text{Nombre de réaccès avec un temps inter-accès égal à t} \approx K \times t^{- \beta}</math>, avec t le temps moyen entre deux réutilisations. Le coefficient <math>\beta</math> est ici compris entre 1.7 et 1.3. De manière générale, les coefficients <math>\alpha</math> et <math>\beta</math> sont reliés par la relation <math>\alpha = 1 - \beta</math>, ce qui montre qu'il y a un lien entre les deux relations. Précisons cependant que la loi de puissance précédente ne vaut pas pour tous les programmes informatiques, mais seulement pour la plupart d’entre eux. Il n'est pas rare de trouver quelques programmes pour lesquels les accès aux données sont relativement prédictibles et où une bonne optimisation du code fait que la loi de puissance précédente n'est pas valide. La loi de puissance des défauts de cache peut se démontrer à partir de la relation précédente, sous certaines hypothèses. Si un suppose que le cache est assez petit par rapport aux données, alors les deux relations sont équivalentes. L'idée qui se cache derrière la démonstration est que si le temps entre deux accès à une donnée est trop long, alors la donnée accédée aura plus de chance d'être rapatriée en RAM, ce qui cause un défaut de cache. La chance de rapatriement dépend de la taille du cache, un cache plus gros peut conserver plus de données et a donc un temps avant rapatriement plus long. ==Les lignes de cache et leurs tags== Du point de vue du processeur, les lectures et écritures se font mot mémoire par mot mémoire. Un processeur avec des entiers de 64 bits recoit des données de 64 bits de la part du cache, et y écrit des mots de 64 bits. Mais quand on regarde comment sont stockées les données à l'intérieur du cache, les choses sont différentes. ===Les lignes de cache=== Les données sont mémorisées dans le cache par blocs de plusieurs bytes, d'environ 64 à 256 octets chacun, qui portent le nom de '''lignes de cache'''. Les lignes de cache sont l'unité de stockage que l'on trouve à l'intérieur du cache, mais elles servent aussi d'unité de transaction avec la mémoire RAM. Sur les caches actuels, on transfère les données entre le cache et la RAM ligne de cache par ligne de cache, dans la limite de la taille du bus mémoire. Mais d'autres caches plus anciens permettaient de faire des transferts plus fins. C’est-à-dire qu'on pouvait mettre à jour quelques octets dans une ligne de cache sans avoir à la recopier intégralement depuis ou dans la mémoire RAM. En théorie, on pourrait imaginer des caches où les données sont stockées différemment, où l'unité serait le mot mémoire, par exemple. Par exemple, sur un processeur 64 bits, on aurait une ligne de cache de 64 bits. Cela aurait l'avantage de la simplicité : les transferts entre le processeur et la mémoire serait de même taille, l'intérieur du cache ressemblerait à son interface montrée au processeur. Mais cela aurait quelques défauts qui sont compensés par l'organisation en lignes de cache de grande taille. Le premier avantage des lignes de cache est lié à la localité spatiale, la tendance qu'on les programmes à accéder à des données proches les unes des autres. Des accès mémoires consécutifs ont tendance à se faire à des adresses proches, qui ont de bonnes chances d'être dans la même ligne de cache. Et des accès consécutifs à une même ligne de cache sont plus rapides que des accès à deux lignes distinctes. Une autre raison est tout simplement que cela simplifie considérablement la circuiterie du cache. Pour une capacité identique, il vaut mieux avoir peu de lignes de cache assez grosses, que beaucoup de petites lignes de cache. La raison est que les circuits du cache, comme le décodeur, l'encodeur et autres, ont moins de sorties et sont donc plus simples. ===L'alignement des lignes de cache=== Les lignes de cache sont des blocs de plusieurs dizaines à centaines de bytes, dont la taille est presque toujours une puissance de deux. De plus, les lignes de cache sont alignées en mémoire. Nous avions déjà abordé la notion d'alignement mémoire dans un chapitre précédent, mais le concept d'alignement des lignes de cache est quelque peu différent. Quand nous avions parlé d'alignement auparavant, il s'agissait de l'alignement des données manipulées par le processeur, qui faisait partie du jeu d'instruction du processeur. Ici, nous parlons d'un alignement totalement différent, invisible pour le programmeur, sans lien avec le jeu d’instruction. Voyons de quoi il retourne. Concrètement, cela veut dire que du point de vue du cache, la RAM est découpée en blocs qui font la même taille qu'une ligne de cache, aux positions prédéterminées, sans recouvrement entre les blocs. Par exemple, pour un cache dont les lignes de cache font 256 octets, le premier bloc est à l'adresse 0, le second est 256 octets plus loin, c'est à dire à l'adresse 256, le troisième à l'adresse 512, la quatrième à l'adresse 768, etc. Une ligne de cache de 256 octets contiendra une donnée provenant d'un bloc de RAM de 256 octets, dont l'adresse est systématiquement un multiple de 256. Il n'est pas possible qu'une ligne de cache contienne un bloc de 256 octets dont l'adresse du premier octet serait l'adresse 64, ou l'adresse 32, par exemple. En clair, les adresses de ces blocs sont des multiples de la taille de la ligne de cache, de la taille des blocs. Cela rappelle les contraintes d'alignement vues dans le chapitre "Le modèle mémoire : alignement et boutisme", mais appliquées aux lignes de cache. L'alignement des lignes de cache a des conséquences pratiques pour la conception des caches. Notons qu'il est en théorie possible d'avoir des caches dont les lignes de cache ne sont pas alignées, mais cela poserait des problèmes majeurs. Il serait en effet possible qu'une donnée soit présente dans deux lignes de cache à la fois. Par exemple, prenons le cas où une ligne de cache de 256 commence à l'adresse 64 et une autre ligne de cache commence à l'adresse 0. L'adresse 128 serait dans les deux lignes de cache ! Et cela poserait des problèmes lors des lectures, mais encore plus lors des écritures. C'est pour éviter ce genre de problèmes que les lignes de cache sont alignées avec la mémoire RAM dans tous les caches existants. L'alignement des lignes de cache est une chose que les programmeurs doivent parfois prendre en compte quand ils écrivent du code ultra-optimisé, destiné à des programmes demandant des performances extrêmes. Il arrive que les contraintes d'alignement posent des problèmes. Nous avions vu dans le chapitre sur le boutisme et l'alignement qu'il valait mieux gérer l'alignement des variables des structures de données, pour éviter les accès non-alignés avec le bus mémoire. La même chose est possible, mais pour l'alignement avec des lignes de cache. Typiquement, l'idéal est que, pour une structure de donnée, on puisse en mettre un nombre entier dans une ligne de cache. Ou alors, si la structure est vraiment grande, que celle-ci occupe un nombre entier de lignes de cache. Si ce n'est pas le cas, il y a un risque d'accès non-alignés, c'est à dire qu'une structure se retrouve à cheval sur deux lignes de cache, avec les défauts que cela implique. ===Le tag d'une ligne de cache=== Plus haut, nous avions dit que le cache mémorise, pour chaque ligne de cache, l'adresse RAM associée. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Mais du fait de l'organisation du cache en lignes de cache de grande taille, qui sont de plus alignées en mémoire, il faut nuancer cette affirmation. Le cache ne mémorise pas la totalité de l'adresse, ce qui serait inutile. L'alignement des lignes de cache en RAM fait que les bits de poids faible de l'adresse ne sont pas à prendre en compte pour l'association adresse-ligne de cache. Dans ces conditions, on mémorise seulement la partie utile de l'adresse mémoire correspondante, qui forme ce qu'on appelle le '''tag'''. Le reste de l'adresse indique quelle est la position de la donnée dans la ligne de cache. Par exemple, prenons le cas où le processeur gère des nombres entiers de 64 bits (8 octets) et des lignes de cache de 128 octets : chaque ligne de cache contient donc 16 entiers. Si le processeur veut lire ou écrire un entier bien précis, il doit préciser sa place dans la ligne de cache. Et ce sont les bits de l'adresse mémoire non-inclus dans le cache qui permettent de faire ça. En clair, une adresse mémoire à lire/écrire est interprété par le cache comme la concaténation d'un tag et de la position de la donnée dans la ligne de cache correspondante. [[File:Adressage d'un cache totalement associatif.png|centre|vignette|upright=2|Adressage d'un cache totalement associatif]] Le cache est donc une grande table de correspondance entre tags et lignes de cache. Lors d'un accès mémoire, le cache extrait le tag de l'adresse à lire ou écrire, et le compare avec les tags de chaque ligne de cache. Si une ligne contient ce tag, alors c'est que cette ligne correspond à l'adresse, et c'est un défaut de cache sinon. Lors d'un succès de cache, la ligne de cache est lue depuis le cache et envoyée à un multiplexeur qui sélectionne la donnée à lire dans la ligne de cache. Le fonctionnement est similaire pour une écriture : la donnée à écrire passe dans un démultiplexeur, qui envoie la donnée au bon endroit dans la ligne de cache sélectionnée. [[File:Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.png|centre|vignette|upright=2|Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.]] ===Le contenu d'une ligne de cache=== Dans ce qui va suivre, nous allons considérer que chaque ligne de cache mémorise son tag, les données de la ligne de cache proprement dit, et quelques bits de contrôle annexes qui varient suivant le cache considéré. [[File:Tag d'une ligne de cache.png|centre|vignette|upright=2|Tag d'une ligne de cache.]] Les caches modernes incluent de nombreux bits de contrôle, mais deux d'entre eux sont communs à presque tous les caches modernes : le bit ''Dirty'' et le bit ''Valid''. Le '''bit ''Valid''''' indique si la ligne de cache contient des données valides ou non. Si le bit ''Valid'' est à 0, la ligne de cache est en état valide, à savoir qu'elle contient des données et n'est pas vide. Par contre, si ce bit est à 1, la ligne de cache est invalide et son contenu ne peut pas être lu ou écrit. L'utilité de ce bit est qu'il permet d'effacer une ligne de cache très rapidement : il suffit de mettre ce bit à 0. Il existe des situations où le cache doit être effacé, on dit alors qu'il est invalidé. Une section de ce chapitre sera dédié à l'invalidation du cache. Le '''bit ''Dirty''''' indique qu'une ligne de cache a été modifiée. Par modifiée, on veut dire que le processeur a écrit dedans, qu'il a modifié la ligne de cache. Mais attention : si la donnée a été modifiée dans le cache, la modification n'est pas forcément propagée en mémoire RAM. Le bit ''dirty'' indique si c'est le cas, si l'écriture a été propagée en mémoire RAM. Il précise que la ligne de cache contient des données modifiées, alors que la RAM a des données initiales non-modifiées. Une ligne de cache avec un bit ''dirty'' à 1 est dite ''dirty'', par métonymie. Nous verrons cela en détail dans la section sur les caches ''write-back'' et ''write-through''. Les caches modernes ajoutent des '''bits de détection/correction d'erreur''' dans les bits de contrôle. Pour rappel, les codes de détection/correction d'erreur permettent de se prémunir contre des erreurs matérielles, qui corrompent les données stockées dans une mémoire, ici une mémoire cache. Ils ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Nous reviendrons dessus dans une section ultérieur de ce chapitre. Sur certains caches assez anciens, on pouvait transférer les lignes de caches morceaux par morceaux. Ces caches avaient des lignes de cache divisées en sous-secteurs, ces sous-secteurs étant des morceaux de ligne de cache qu'on pouvait charger indépendamment les uns des autres (mais qui sont consécutifs en RAM). Chaque secteur avait ses propres bits de contrôle, mais le tag était commun à tous les secteurs. [[File:Cache à secteurs.png|centre|vignette|upright=2.5|Cache à secteurs.]] : Dans ce qui va suivre, le terme "ligne de cache" désignera soit un bloc de données copiées depuis la RAM d'une taille de 64/128/256/... octets, soit la concaténation de ces données avec le tag et des bits de contrôle. Les deux définitions ne sont pas équivalentes, mais l'usage a entériné cet abus de langage. Et il faut avouer que cela rend les explications du chapitre plus simples. ==Les instructions de contrôle du cache== Plus haut, nous avions dit que le cache est totalement transparent du point de vue du programmeur. Le cache contient des copies de données en RAM, le programmeur n'a rien à faire pour utiliser le cache correctement. Mais la réalité est que pour des raisons diverses, des processeurs incorporent des '''instructions de contrôle du cache'''. Il s'agit d’instructions qui agissent sur le contenu du cache. Elles existent pour des raisons diverses qu'on détaillera plus bas, mais il s'agit globalement d'une question de performances ou de nécessité pour le système d'exploitation. ===Les instructions de préchargement=== La première instruction de contrôle du cache est une '''instruction de préchargement''', qui demande à charger un bloc de données dans le cache. Elle prend en opérande une adresse mémoire, et le contenu de cette adresse est chargé dans une ligne de cache. Bien sûr, des contraintes d'alignement sont à prendre en compte : on charge un bloc de la même taille qu'une ligne de cache, aligné en mémoire sur la taille du bloc, qui contient l'adresse. L'instruction de préchargement n'est utile que si l'instruction est exécutée bien avant que la donnée ne soit utilisée/lue/écrite. Cela permet de charger une donnée dans le cache à l'avance, d'où le nom de préchargement donné à cette technique. Mais les processeurs modernes gérent des techniques de préchargement automatique, qui ne requièrent pas d'instructions de préchargement. Le préchargement automatique et les instructions de préchargement sont deux solutions complémentaires, mais qui peuvent se marcher sur les pieds. Nous en reparlerons dans le prochain chapitre, qui sera dédié au préchargement automatique. Il faut noter que les instructions de préchargement peuvent être ignorées par le processeur. Sous certaines conditions, le processeur peut décider que l'instruction de préchargement ne sera pas exécutée. Par exemple, il ne va pas précharger une donnée déjà présente dans le cache. Ou encore, si le bus mémoire est occupé, il ne va pas exécuter le préchargement, par manque de ressources matérielles. ===Les instructions d'invalidation et de ''flush''=== Les instructions ''flush'' regroupent deux types d'instructions qui sont souvent utilisées en même temps. Il s'agit des instructions d'invalidation et de nettoyage (''clean''). Les deux termes proviennent de la terminologie ARM, il n'y a pas de terminologie standardisé pour les noms de ces instructions. Dans les grandes lignes, elles permettent de vider le cache, à savoir de rapatrier son contenu en RAM et de réinitialiser le cache à zéro. Elles sont utilisées par le système d'exploitation lors des commutations de contexte, à savoir quand on passe d'un programme à un autre. Elles sont aussi utilisées lors des appels systèmes et routines d'interruption/exception. L'idée est de vider le cache avant d'exécuter un nouveau programme ou une nouvelle routine. Le nouveau programme aura accès à un cache tout propre, les données de l'ancien programme auront été retirée du cache. Les '''instructions ''clean''''' recopient le contenu de la ligne de cache en RAM. Elles forcent la recopie immédiatement de la ligne de cache en mémoire RAM. Pour faire leur travail, elle vérifient si la ligne de cache a été modifiée, avant de la recopier en RAM. Et pour cela, ils vérifient le bit de contrôle ''dirty'', qui est mis à 1 après une première écriture. Si ce bit est à 0, alors pas besoin de recopier la ligne de cache : elle n'a pas été modifiée, la RAM a déjà la bonne copie. Mais s'il est à 1, le cache et la RAM n'ont pas le même contenu, la recopie s'exécute. Les '''instructions d'invalidation''' permettent d'invalider une ligne de cache, à savoir d'effacer son contenu. Nous verrons à quoi servent ces instructions dans la section sur les changement de processus. Invalider une ligne de cache est une opération optimisée : le cache n'est en réalité pas réellement effacé. À la place, le bit ''Valid'' de chaque ligne de cache est juste mis à 0. Il faut noter que l'invalidation efface les lignes de cache sans se préoccuper de leur contenu. Elle se moque qu'une ligne de cache contienne une donnée modifiée, ''dirty'' ou quoique ce soit : la ligne de cache est effacée, point. Il est possible d'invalider une ligne de cache en fournissant une adresse mémoire, mais il est aussi possible d'invalider le cache tout entier. Le choix entre les deux dépend du mode d'adressage de l'instruction d'invalidation. Parfois, il existe une instruction séparée pour invalider tout le cache, et une autre pour invalider une ligne de cache bien précise. Des instructions séparées sont parfois disponibles pour invalider les caches de données et d'instructions, parfois aussi la TLB (un cache qu'on verra dans quelques chapitres). Il est possible de n'invalider que le cache L1, voire le cache L2. Il faut noter que l'invalidation efface tout le cache, mais ne se préoccupe pas de vérifier si les données ont été modifiées dans le cache. Pour certains caches, comme le cache d'instruction, ce n'est pas un problème, vu qu'il est en "lecture seule". Mais pour les caches de données, les données modifiées sont perdues en cas d'invalidation. Heureusement, il existe des instructions d'invalidation qui fusionnent une instruction ''clean'' et une instruction d'invalidation. Il s'agit d''''instructions d'invalidation spéciales'''. ===Les instructions d'optimisation : instructions non-temporelles et écritures optimisées=== Les '''instructions mémoire non-temporelles''' contournent complètement le cache. Par exemple, une lecture peut lire une donnée, mais celle-ci ne sera pas chargée dans le cache, elle passe directement de la RAM vers les registres. Une section entière de ce chapitre sera dédiée au contournement du cache, à savoir aux situations où les accès mémoire doivent passer directement du processeur à la RAM sans passer par le cache. D'autres instructions assez rares incorporent des indications pour le cache. Par exemple, l'instruction ''load last'' des processeurs POWER PC implique que la donnée ne sera utilisée qu'une seule fois. Elle est donc chargée dans le cache, mais la ligne de cache est configurée de manière à être remplacée très rapidement, typiquement avec une valeur de LRU/LFU adéquate. La donnée est bien chargée dans le cache, au cas où elle doive être relue suite à une mauvaise prédiction de branchement ou autre, chose qu'une lecture non-temporelle (qui contourne le cache) ne fait pas. Des indications de ce type sont appelées des '''''cache hint'''''. L''''instruction ''flush''''' permet de préciser qu'une ligne de cache contient une donnée inutile, qui ne sera pas réutilisée par le programme. Pas besoin de la conserver dans le cache, elle peut laisser sa place à des données plus utiles. Or, sans indication, les algorithmes de remplacement d'une ligne de cache risquent de conserver cette donnée trop longtemps, ce qui entraine une certaine pollution du cache par des données inutiles. Une autre instruction est elle beaucoup plus importante : celle de '''pré-allocation sur écriture'''. Elle sert dans le cas où une ligne de cache est complétement écrite. Par exemple, imaginons qu'on veuille écrire dans une portion de mémoire. Si celle-ci n'est pas dans le cache, le processeur va charger une ligne de cache complète depuis la RAM, écrire dans la ligne de cache, puis recopier la ligne de cache modifiée en mémoire RAM. Une écriture en RAM demande donc de faire une lecture et une écriture. Mais les instructions de pré-allocation sur écriture permettent de prévenir qu'une ligne de cache sera intégralement écrite, et qu'il n'y a donc pas besoin de lire celle-ci depuis la RAM. Notons que l'instruction d'écriture qui suit n'est pas une écriture non-temporelle, vu que les données sont écrites dans la ligne de cache, qui est ensuite envoyée en mémoire RAM dès que nécessaire. De plus, les données écrites peuvent ensuite être relue depuis le cache si nécessaire. Enfin, certains processeurs MIPS incorporent une instruction pour modifier le tag d'une ligne de cache. Elles servent à optimiser les copies mémoire, à savoir quand on copie un bloc de données d'un endroit à un autre. L'idée est de charger le bloc de données dans le cache avec une instruction LOAD/PREFETCH, de modifier le tag pour qu'il pointe vers l'adresse à écrire, et de laisser faire le cache pour que l'écriture se fasse en RAM. Mais les contraintes pour utiliser cette instruction sont assez drastiques : les données doivent être alignées sur la taille d'une ligne de cache, le bloc de départ et d'arrivée (l'original versus la copie) ne doivent pas se recouvrir, etc. ==L'associativité des caches et leur adressage implicite== Lorsqu'on souhaite accéder au cache, il faut trouver quelle est la ligne de cache dont le tag correspond à l'adresse demandée. On peut classifier les caches selon leur stratégie de recherche de la ligne correspondante en trois types de caches : totalement associatifs, directement adressés (''direct mapped'') et associatifs par voie. ===Les caches totalement associatifs=== Avec les caches totalement associatifs, toute donnée chargée depuis la mémoire peut être placée dans n'importe quelle ligne de cache, sans aucune restriction. Ces caches ont un taux de succès très élevé, quand on les compare aux autres caches. [[File:Cache totalement associatif.png|centre|vignette|upright=2|Cache totalement associatif.]] Concevoir un cache totalement associatif peut se faire de deux grandes manières différentes. La première consiste tout simplement à combiner une mémoire associative avec une mémoire RAM, en ajoutant éventuellement quelques circuits annexes. La mémoire associative mémorise les tags, alors que la mémoire RAM mémorise les données de la ligne de cache, éventuellement avec quelques bits de contrôle. La ligne de cache est stockée à une adresse A dans la mémoire RAM et son tag est stocké à la même adresse, mais dans la mémoire CAM. Ce faisant, quand on envoie le tag à la mémoire CAM, elle renvoie l'adresse de la ligne de cache dans la mémoire RAM. Cette adresse est alors envoyée directement sur le bus d'adresse de la RAM, et la lecture est effectuée automatiquement. Il faut ajouter quelques circuits annexes pour garantir que les écritures se passent correctement dans les deux mémoires, mais rien de bien terrible. [[File:Cache fabriqué avec une mémoire associative et une RAM.png|centre|vignette|upright=3|Cache fabriqué avec une mémoire associative et une RAM]] Il est cependant possible d'optimiser un tel cache, en fusionnant la mémoire CAM et la mémoire RAM, afin d'éliminer des circuits redondants. Pour comprendre pourquoi, rappelons que les mémoires CAM sont composées d'un plan mémoire, d'un paquet de comparateurs et d'un encodeur. Quant à la mémoire RAM, elle est composée d'un décodeur connecté au plan mémoire. En mettant une CAM suivie d'une RAM, on a un encodeur dont l'entrée est envoyée à un décodeur. [[File:Cache totalement associatif naif.png|centre|vignette|upright=3|Cache totalement associatif naif]] Or, le décodeur réalise l'opération inverse de l'encodeur, ce qui fait que mettre les deux composants à la suite ne sert à rien. On peut donc retirer l'encodeur et le décodeur, et envoyer directement les résultats des comparateurs sur les entrées de commande du plan mémoire de la RAM. [[File:Cache totalement associatif optimisé.png|centre|vignette|upright=2|Cache totalement associatif optimisé]] Avec cette méthode, les circuits du cache ressemblent à ce qui illustré ci-dessous. Le tag est envoyé à chaque ligne de cache. Le tag envoyé est alors comparé avec le Tag contenu dans chaque ligne de cache, comme c'est le cas sur les mémoires associatives. Si une ligne de cache matche avec le tag envoyé en entrée, la ligne pour laquelle il y a eu une égalité est alors connectée sur les lignes de bit (''bitlines''). Cela est réalisé par un circuit commandé par le comparateur de la ligne de cache. Il ne reste plus qu'à sélectionner la portion de la ligne de cache qui nous intéresse, grâce à un paquet de multiplexeurs. Cela permet d'effectuer une lecture ou écriture, mais il faut aussi préciser si il y a eu un défaut de cache ou un succès. Un succès de cache a lieu quand au moins des comparaisons est positive, alors que c'est un défaut de cache sinon. En clair, détecter un succès de cache demande juste de connecter une porte OU à plusieurs entrées à tous les comparateurs. [[File:Organisation générale d'un cache totalement associatif.png|centre|vignette|upright=2|Organisation générale d'un cache totalement associatif.]] ===Les caches directement adressés=== Les caches directement adressés peuvent être vus comme un cache totalement associatif auquel on aurait ajouté des restrictions assez drastiques. Plus haut, on a vu qu'un cache totalement adressé est équivalent à la combinaison d'une CAM avec une RAM. La mémoire CAM prend en entrée un Tag et traduit celui-ci en une adresse qui commande la mémoire RAM interne au cache. Dans ce qui suit, l'adresse interne au cache sera appelé l''''indice''' pour éviter toute confusion. [[File:Cache hash table - 2.png|centre|vignette|upright=2|Fonctionnement interne du cache, expliquée sous forme abstraite, en utilisant la notion d'indice interne au cache.]] Les caches directement adressés cherchent à remplacer la mémoire CAM par un circuit combinatoire. Ce circuit traduit le Tag en indice, mais est beaucoup plus simple qu'une mémoire CAM. Mais qui dit circuit plus simple dit circuit plus limité. Un circuit combinatoire n'est pas aussi versatile que ce qui est permis avec une mémoire CAM. En conséquence, une restriction majeure apparait : toute adresse mémoire est associée dans une ligne de cache prédéfinie, toujours la même. L'association entre ligne de cache et adresse mémoire est faite par le circuit combinatoire, et ne peut pas changer. Les concepteurs de caches s'arrangent pour que des adresses consécutives en mémoire RAM occupent des lignes de cache consécutives, par souci de simplicité. Tout se passe comme suit la mémoire RAM était découpés en blocs de la même taille que le cache. La première adresse du bloc est associée à la première ligne de cache (celle d'indice 0), la seconde adresse est associée à la seconde adresse du_ bloc, et ainsi de suite. Le tout est illustré ci-dessous. [[File:Cache adressé directement.png|centre|vignette|upright=2|Cache adressé directement.]] Avec cette contrainte, le circuit de traduction de l'adresse en adresse mémoire pour la RAM interne au cache est drastiquement simplifié, et disparait même. Une partie de l'adresse mémoire sert à indiquer la position de la donnée dans le cache, le reste de l'adresse sert encode le tag et la position de la donnée dans le ligne de cache. [[File:Cache line.png|centre|vignette|upright=2|Adresse d'une ligne de cache sur un cache adressé directement.]] Un cache directement adressé est conçu avec une RAM, un comparateur, et un paquet de multiplexeurs. En général, la mémoire RAM stocke les lignes de caches complète. Il arrive que l'on utilise deux mémoires RAM : une pour les tags et une pour les données, mais cette technique augmente le nombre de circuits et de portes logiques nécessaires, ce qui réduit la capacité du cache. L'index à lire/écrire est envoyé sur l'entrée d'adresse de la RAM, la RAM réagit en mettant la ligne de cache sur sa sortie de donnée. Sur cette sortie, un comparateur compare le tag de la ligne de cache lue avec le tag de l'adresse à lire ou écrire. On saura alors si on doit faire face à un défaut de cache. Ensuite, un multiplexeur récupère la donnée à lire/écrire. [[File:Direct mapped cache - french.png|centre|vignette|upright=2|Cache directement adressé.]] L'accès à un cache directement adressé a l'avantage d'être très rapide vu qu'il suffit de vérifier une seule ligne de cache : celle prédéfinie. Mais ces caches ne sont cependant pas sans défauts. Vu que le cache est plus petit que la mémoire, certaines adresses mémoires se partagent la même ligne de cache. Si le processeur a besoin d’accéder fréquemment à ces adresses, chaque accès à une adresse supprimera l'autre du cache : tout accès à l'ancienne adresse se soldera par un défaut de cache. Ce genre de défauts de cache causés par le fait que deux adresses mémoires ne peuvent utiliser la même ligne de cache s'appelle un '''défaut par conflit''' (''conflict miss''). Les défauts par conflit n'existent pas sur les caches totalement associatifs. En conséquence, le taux de succès des caches directement adressés est assez faible comparé aux autres caches. [[File:Cache Block Basic Conflict.svg|centre|vignette|upright=1.5|Exemple de ''Conflict Miss''.]] ===Les caches associatifs par voie=== Les caches associatifs par voie sont un compromis entre les caches directement adressés et les caches totalement associatifs. Pour simplifier, ces caches sont composés de plusieurs caches directement adressés accessibles en parallèle, chaque cache/RAM étant appelé une '''voie'''. Avec ces caches, toute adresse mémoire en RAM est associée à une ligne de cache dans chaque voie. [[File:Cache associatif par voie.png|centre|vignette|upright=2|Cache associatif par voie.]] Le schéma ci-dessous compare un cache directement adressé et un cache associatif à deux voies. On voit que chaque adresse est associée à une ligne de cache bien précise avec un cache directement dressé, et à deux lignes de cache avec un cache associatif à deux voies. L'adresse sera associée à 4 lignes de cache sur un cache associatif à 4 voies, à 8 lignes pour un cache à 8 voies, etc. L'ensemble des lignes de cache associées à une adresse est appelé un '''ensemble'''. [[File:Cache Fill.svg|centre|vignette|upright=2|Comparaison entre un cache directement adressé et un cache associatif à deux voies.]] Sur ces caches, toute adresse est découpée en trois parties : un tag, un index, et un décalage, comme sur les caches directement adressés. Comme vous pouvez le voir, l'organisation est identique à celle d'un cache totalement associatif, à part que chaque ensemble tag-ligne de cache est remplacé par une mémoire RAM qui en contient plusieurs. [[File:Implémentation d'un cache associatif par voie.png|centre|vignette|upright=2|Implémentation d'un cache associatif par voie.]] Le risque de conflits d'accès au cache est donc réduit sur un cache associatif à plusieurs voies, et il est d'autant plus réduit que le cache a de voies. Par contre, leur conception interne fait qu'ils ont un temps d'accès légèrement élevé que les caches directement adressés. Les caches associatifs par voie ont donc un taux de succès et un temps d'accès intermédiaire, situé entre les caches directement adressés et totalement associatifs. Ils sont une sorte de compromis entre réduction des défaut par conflits d'accès au cache et temps d'accès, et complexité des circuits. ==Les optimisations des caches associatifs par voie== Les caches partiellement associatifs regroupent les caches associatifs par voie et directement adressés, ainsi que leurs variantes. En clair : tous les caches qui ne sont pas totalement associatifs. Ils peuvent être optimisés de nombreuses manières, que ce soit pour gagner en performance ou pour économiser de l’énergie. Dans cette section, nous allons voir quelles sont ces optimisations. ===Les caches pseudo-associatifs=== Les caches adressés par voie contiennent une mémoire SRAM par voie. En théorie, les voies sont accédées en parallèles, en même temps, afin de voir si l'on a un succès de cache ou un défaut. Les '''caches pseudo-associatifs''' sont identiques aux caches associatifs par voie, si ce n'est qu'ils vérifient chaque voie une par une. Ils ont été utilisés sur des processeurs commerciaux, un exemple étant l'IBM 370. Là encore, on perd en performance pour gagner en consommation d'énergie. Le temps d'accès dans le meilleur des cas est plus faible pour les caches pseudo-associatifs, mais le pire des cas teste tous les caches avant de tomber sur le bon. Les performances sont donc réduites. Mais la consommation énergétique est meilleure, vu qu'on ne vérifie pas forcément toutes les voies en parallèle. On teste la première voie, éventuellement la seconde, peut-être la troisième, etc. Mais dans le cas général, on ne teste qu'une partie des voies, pas toutes, ce qui donne un gain en termes d'énergie. L'implémentation de caches de ce genre demande que l'on parcoure les voies une par une, en commençant de la première jusqu'à la dernière. Pour cela, un simple compteur suffit. Suivant la valeur du compteur, la voie associée est activée puis accédée. Toute la complexité revient à ajouter un circuit qui prend la valeur du compteur, et active la voie associée, lance un accès mémoire dessus. Vu que les voies sont chacune des caches ''direct mapped'', il suffit pour cela de geler les entrées d'adresse, soit en les déconnectant, soit en utilisant du ''clock gating'' ou de l'évaluation gardée. Les détails d'implémentation, non-cités ici, varient selon le cache. ===La prédiction de voie=== Pour réduire le temps d'accès des caches pseudo-associatifs, certains chercheurs ont inventé la '''prédiction de voie''', qui consiste à faire des paris sur la prochaine voie accédée. L'idée est d'accéder à la voie qui contient la donnée voulue du premier coup, en lisant celle-ci en priorité. Dans son implémentation la plus simple, le cache reste un cache pseudo-associatif. Lors d'un accès au cache, les voies sont toutes parcoures une par une. Par contre, les voies ne sont donc pas parcourues de la première vers la dernière, mais dans un ordre différent. Cette technique permet de mettre en veille les voies sur lesquels le processeur n'a pas parié, ce qui permet de diminuer la consommation énergétique du processeur. C'est plus efficace que d'aller lire plusieurs données dans des voies différentes et de n'en garder qu'une. L'implémentation est assez simple : il suffit d'ajouter un circuit de prédiction de voie,relié au compteur de voie. Une amélioration de la technique fait fonctionner le cache comme un intermédiaire entre cache pseudo-associatif et associatif par voies. L'idée est de chercher la voie prédite en premier, puis de chercher dans toutes les voies en parallèle en cas de défaut de cache. Au lieu d'attendre que les comparaisons de tags donnent leur résultat, le processeur sélectionne automatiquement une voie et configure les multiplexeurs à l'avance. Si le processeur ne se trompe pas, le processeur accède à la donnée plus tôt que prévu. S'il se trompe, le processeur annule la lecture effectuée en avance et recommence en faisant un accès en parallèle aux autres voies. Le compromis entre performance et consommation d'énergie est alors différent. On économise de l'énergie par rapport à un cache associatif par voie, au prix d'une petite perte de performance (doublement des temps d'accès). Mais par rapport à un cache pseudo-associatif, l'économie d'énergie est bien moindre, au prix d'un gain en performance assez manifeste. Prédire quelle voie sera la bonne est assez simple. En vertu du principe de localité, les accès futurs ont des chances de tomber dans les voies les plus fréquemment utilisées ou dans celle plus récemment utilisée. Il suffit de retenir la voie la plus récemment accédée dans un registre, qui sera utilisée comme prédiction. Pour vérifier que la prédiction est correcte, il suffit de comparer le registre et le résultat obtenu après vérification des tags. Cependant, on peut complexifier l'implémentation pour prendre en compte l'adresse à lire/écrire, l'instruction à l'origine de l'accès mémoire ou tout autre paramètre utile. Par exemple, des instructions différentes ont tendance à aller chercher leurs données dans des ensembles différents et la voie à choisir n'est pas la même. Pour cela, il suffit d'utiliser un cache pour stocker la correspondance instruction - voie. Pour plus de simplicité, la mémoire cache des prédictions est parfois remplacée par une RAM, qui est adressée : * soit par le program counter de l'instruction à l'origine de l'accès (en réalité, seulement quelques bits de poids faible de l'adresse) ; * soit par l'adresse à accéder (là encore, quelques bits de poids faible) ; * soit (pour les modes d'adressage qui utilisent un registre de base et un décalage) par un XOR entre les bits de poids faible de l'adresse de base et le décalage ; * soit par autre chose. ===La mise en veille sélective des voies=== Les caches associatifs ont tendance à utiliser beaucoup d'énergie, même quand on n'y accède pas. Aussi, certains processeurs détectent quand le cache est peu utilisé et en profitent pour mettre en veille les voies inutilisées. Vous vous demandez certainement ce qui se passe quand une donnée à lire/écrire est dans une voie désactivée. La réponse est que le cache détecte cette situation, car elle déclenche un succès de cache. Les ''tags'' ne sont en effet pas désactivés, seules les données sont mises en veille. L'implémentation est plus simple sur les caches qui séparent les tags et les données dans deux RAM différentes. Cette optimisation marche surtout sur les gros caches, qui ont des chances d'avoir une portion significative d’inutilisée (pas assez de données pour les remplir), donc généralement les caches L3/L4. Par exemple, les processeurs d'Intel de microarchitecture Ivy Bridge disposent d'un cache de 8 mébioctets à 16 voies, qu'ils peuvent faire passer à 512 kibioctets si le besoin s'en fait sentir. Quand ces processeurs détectent une faible activité, ils mettent en veille 14 voies et n'en gardent que 2 d'actives. Évidemment, les 14 voies sont vidées avant d'être mises en veille, afin qu'une aucune donnée ne soit perdue. ===Les caches ''skew-associative''=== Vous aurez remarqué que dans une voie, les lignes sont accédées en adressage direct : les défauts par conflit sont possibles sur un cache associatif par voie. Pour éviter cela, certains chercheurs ont créé des '''caches ''skew associative''''' (ou associatifs à biais). Pour faire simple, les index des lignes de cache subissent un petit traitement avant d'être utilisés. Le traitement en question est différent suivant la voie de destination, histoire que deux adresses mémoires avec des index identiques donnent des index différents après traitement. Le traitement en question est souvent une permutation des bits de l'index, qui est différente suivant la voie prise, ou un simple XOR avec un nombre qui dépend de la voie. [[File:Implémentation d'un cache skew associative.jpg|centre|vignette|upright=2|Implémentation d'un cache skew associative.]] ==Les caches splittés (''phased caches'')== Dans cette section, nous allons voir les '''caches splittés''' (''phased caches''), qui sont une variante des caches ''direct-mapped'', dans lequel le cache est accédé en deux étapes consécutives. Il ne s'agit pas des caches pipelinés, que nous verrons dans le chapitre sur les processeurs pipélinés, mais laissons cela à plus tard. Il est possible d'appliquer la même méthode sur un cache associatif par voie, mais il y a des méthodes plus simples, qui permettent là aussi d’accéder au cache en plusieurs étapes consécutives. L'idée est de scinder le cache en deux : une mémoire pour les tags, une autre pour les données de la ligne de cache. Les bits de contrôle peuvent être mis dans l'une ou l'autre SRAM, mais ils sont souvent mis dans la RAM pour les tags. En faisant cela, quelques optimisations deviennent possibles, afin de réduire la consommation énergétique en contrepartie d'une perte de performance. La technique s'implémente différemment pour les caches totalement associatifs et partiellement associatifs. Les caches totalement associatifs splittés sont ceux formés en combinant un cache associatif avec une CAM et une RAM combinée. On envoie l'adresse à lire/écrire à la mémoire associative, elle répond en envoyant une adresse à la mémoire RAM. L'accès se fait donc en deux temps, avec l'adresse dans la RAM comme intermédiaire. Il est possible de séparer physiquement les deux étapes en insérant un registre entre la CAM et la RAM, ce qui permet aussi de pipeliner l'accès. Mais c'est rarement fait en pratique, car le cout en circuit d'une mémoire CAM est trop important. L'équivalent pour un cache totalement associatif optimisé, sans CAM et RAM séparée, est trop gourmande en interconnexions pour être implémentée. Les caches totalement associatifs splittés sont donc très rares, l'auteur ne connait aucun exemple de processeur avec un tel cache. Il existe une technique équivalente pour les caches ''direct-mapped'', mais elle demande une certaine modification du cache. Dans les caches ''direct-mapped'' non-splittés, on trouve une mémoire SRAM dont chaque mot mémoire contient une ligne de cache entière, tag inclus. Dans leurs versions splittés, la SRAM est séparée en deux : une pour les tags, une autre pour les données. Précisons qu'il s'agit bien de deux mémoires SRAM adressables. L'adresse à laquelle accéder est envoyée à la SRAM des tags, puis ensuite à la SRAM des données si besoin. L'idée est d’accéder aux tags pour déterminer s'il y a un succès de cache ou un défaut, et ensuite d'accéder aux données. On n’accède pas aux données en parallèle des tags. Faire cela est évidemment plus lent. En cas de défaut de cache, le temps d'accès est similaire : le tag ne correspond pas, on n'accède pas à la SRAM pour les données. Par contre, vu qu'on n'a pas activé la SRAM pour les données, on économise un peu d'énergie, ce qui réduit la consommation d'énergie. En cas de succès de cache, on accède à la SRAM pour les tags, puis à celle pour les données. Pas d'économie d'énergie à l'horizon, sans compter que le temps d'accès augmente : on accède au cache en deux étapes au lieu de faire les deux accès en parallèle. [[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]] Précisons cependant que ce design peut avoir deux avantages en termes de performance. Premièrement, le temps d'accès au cache est légèrement amélioré en cas de défaut de cache. En effet, la SRAM des tags est assez petite, idem pour celle des données. Leur temps d'accès est donc plus faible que pour une grosse SRAM contenant données et tags. Le gain en temps d'accès est donc un avantage, qui ne se manifeste surtout en cas de défaut de cache. Un autre avantage est que l'accès au cache se pipeline plus facilement, ce qui fait qu'on peut effectuer plusieurs accès simultanés au cache. Mais nous verrons cela dans quelques chapitres. ===L'exemple des processeurs 386 et du contrôleur de cache 82385=== Il est important de noter que la séparation entre tags et RAM peut être telle que les deux ne sont pas sur la même puce de silicium ! Voire que les deux sont séparés du processeur ! C'était le cas quand les mémoires caches ont été introduites sur les processeurs grand public, notamment sur les premiers processeurs Intel. La miniaturisation n'avait pas avancé au point où placer un cache dans le processeur était possible. Sur le processeur 386 d'Intel, le cache était un cache splitté, séparé du processeur. Concrètement, le processeur i386 était couplé à un contrôleur de cache Intel 82385 contenant les ''tags'' et les bits de contrôle, et une mémoire SRAM pour les lignes de cache. Le 82385 surveillait ce qui se passait sur le bus de données et répondait à la place de la RAM pour certaines lectures. Le schéma ci-dessous montre comment le cache s'intégrait avec le bus système. Pour le bus de commande, le cache servait d'intermédiaire : il recevait les commandes et et les filtrait suivant les succès/défauts de cache. En cas de succès de cache, les commandes de lecture n'étaient pas envoyées à la mémoire RAM. Les adresses étaient transmises directement au cache, mais aussi au bus système (avec un registre entre les deux). Le bus de donnée était lui connecté à la mémoire SRAM et au processeur, avec des MUX/DEMUX pour faire le choix de la source des lectures. Le cache était un cache ''write through''. [[File:Controleur de cache 82385 pour l'Intel 386.png|centre|vignette|upright=2.5|Contrôleur de cache 82385 pour l'Intel 386]] Il pouvait commander soit un cache ''direct mapped'', soit associatif à deux voies. La différence entre les deux est que le 82385 était relié à une mémoire SRAM avec un cache ''direct mapped'', deux SRAM pour deux voies. Pour avoir un cache associatif à deux voies, le 82385 devrait gérer deux signaux ''chip select'' pour activer chaque SRAM/voie suivant les besoins. Il avait précisément quatre signaux CS : deux par SRAM, un pour les lectures, un pour les écritures. Notons que les SRAM utilisées devaient être des SRAM avec un bus de données de 32 bits, des SRAM 16 ou 8 bits ne fonctionnaient pas. Le 82385 rajoutait 4 sorties, pour masquer chaque octet dans ces 32 bits. Le choix entre les deux était le fait d'une entrée : la mettre à 0 indiquait un cache ''direct mapped'', la mettre à 1 forçait un cache à deux voies. Il gérait aussi les accès mémoire non-cacheable, à savoir des accès mémoire qui ne doivent pas être pris en compte par le cache. Pour cela, il avait une entrée NCA (''Non Cacheable Access'') : le cache était "désactivé" quand cette entrée était à 1. C'est un peu une sorte de ''chip select'' pour le 82385, limitée aux accès mémoire. Cette entrée permettait de programmer des intervalles d'adresse auxquels ne pas répondre, en utilisant des circuits de décodage d'adresse adaptés. Il avait aussi une entrée X16, qui permettait d'identifier les accès soit à un composant 16 bits, soit à une entrée-sortie. De tels accès ne doivent pas être mis en cache, ce qui était garanti par cette entrée. Pour rappel, le processeur utilisait un espace d'adressage séparé pour les entrées-sorties, et utilisait donc un bit IO. Ce bit IO pouvait être utilisé pour déterminer le bit X16, qui inhibe l'action du cache. Cette surveillance du bus permettait de gérer une forme limitée de cohérence des caches par invalidation. Dès que le 82385 détectait une prise de contrôle du bus par autre chose que le processeur, il surveillait les adresses transmises sur le bus. En cas de succès de cache, la ligne de cache associée était invalidée. Au-delà de ca, le 82385 avait une entrée FLUSH, qui ordonnait une invalidation totale du cache. Si cette entrée est mise à 1, toutes les lignes de cache sont invalidées. Les ''tags'' sont marqués comme invalides, mais les lignes de cache elles-mêmes ne sont pas touchées. Le 82385 pouvait être intégré dans un système à deux processeurs, voire plus. Pour cela, chaque processeur avait son propre 82385 et sa SRAM rien qu'à lui. Il n'y avait pas de cache partagé entre les deux processeurs. Par contre, les deux caches étaient reliés au même bus système. Pour qu'ils ne se marchent pas sur les pieds, il y avait des circuits d'arbitrage pour gérer l'accès au bus. Un des deux 82385 était mis en mode maitre, l'autre était en mode esclave. Le 82385 maitre pouvait prendre le contrôle du bus, le 82385 esclave devait demander l'autorisation au premier pour accéder au bus système. ===L'exemple des processeurs Intel de microarchitecture ''Broadwell''=== Un autre exemple est celui du cache L4 des processeurs Broadwell et de quelques processeurs séparés. Ces processeurs ont une organisation en ''chiplet'' où le processeur incorpore plusieurs puces séparées : une puce pour le processeur proprement dit, une puce nommée ''Crystal Well'' pour le cache L4, et une puce IO pour la communication avec la RAM et la carte mère. Le processeur incorporait un cache L4 de 128 mébioctets, composé de mémoire eDRAM, qui était dispersé entre ''Crystal Well'' et les autres puces. Les données du cache L4 étaient dans ''Crystal Well'', alors que les Tags étaient soit dans le processeur lui-même, soit dans la puce IO ! La puce ''Crystal Well'' était une mémoire DRAM adressable tout ce qu'il y a de plus basique, avec cependant quelques optimisations notables. Par exemple, elle avait deux bus séparés pour l'écriture et la lecture. De plus, elle avait une organisation interne avec 128 banques, contre moins d'une dizaine pour la DDR de l'époque et environ 32 banques pour la DDR5 moderne. Elle contenait aussi quelques circuits pour gérer son rôle de mémoire cache, mais rien en ce qui concerne la gestion des tags eux-mêmes. Sur les processeurs de microarchitecture ''Broadwell'', les tags étaient placés dans le CPU et précisément dans le cache L3. À chaque accès mémoire au cache L3, les tags du cache L4 étaient consultés en parallèle. De fait, l'accès au cache L4 était assez rapide, malgré le fait que les données étaient dans une puce à part. Ajoutons à cela que le processeur et ''Crystal Well'' n'avaient pas la même finesse de gravure ni la même technologie de fabrication. Les tags étaient implémentés avec de la SRAM contre la DRAM pour les données, ce qui fait que la consultation des tags était plus rapide que l'accès aux données. Par la suite, dans certains CPU de microarchitecture ''skylake'', les tags ont été déplacés en-dehors du processeur pour finir dans le contrôleur mémoire. En faisant cela, le cache L4 pouvait être utilisé par autre chose que le processeur, et notamment par la carte graphique intégrée au CPU. Avec ''broadwell'', le fait que les tags étaient consultés en cas d'accès au L3 empêchait au GPU intégré de consulter le cache L4. Mais en déplaçant les tags dans le contrôleur mémoire, ce n'est plus le cas vu que la carte graphique a aussi accès au bus mémoire. Par contre, le temps d'accès augmente comparé à la solution précédente. On n'accède pas aux tags du L4 en parallèle du L3 : à la place, il faut consulter les tags du L3, détecter un défaut de cache L3, et ensuite accèder aux tags. ===Les caches RAM-configurables=== Un autre avantage des caches splittés est qu'on peut les modifier pour servir à la fois de mémoire cache, mais aussi de ''local store'', de mémoire RAM de petite taille. Le fonctionnement est assez simple à comprendre. Lors d'un accès au cache, on accède aux tags, puis à la RAM interne au cache. Lors d'un accès au ''local store'', on contourne l'accès au tags et on accède à la RAM interne au cache directement. Il s'agit de la technique du '''cache RAM-configurable''. L'usage de cache RAM-configurable est fréquent sur les cartes graphiques récentes, qui incorporent un ou plusieurs processeurs multicoeurs, dont le cache L1 de données est un cache RAM-configurable. [[File:Hydride cache - local store.png|centre|vignette|upright=2.0|Hydride cache - local store]] ===La compression de cache=== Une autre optimisation permise par les ''phased caches'' est l'implémentation de techniques de '''compression de cache''', qui visent à compresser des lignes de cache. L'intérêt est qu'on peut stocker plus de données dans le cache, à capacité égale. L'inconvénient est qu'on doit compresser/décompresser les lignes de cache, ce qui demande un circuit en plus et allonge les temps d'accès. En effet, le temps mis pour compresser/décompresser une ligne de cache s'ajoute au temps d'accès. Aussi, la compression de cache sert surtout pour les caches de bas niveau dans la hiérarchie mémoire, les gros caches aux temps d'accès assez longs. Une première technique, assez simple à implémenter et peu couteuse en circuit, est celle de la '''compression des lignes de cache nulles'''. Elle compresse uniquement les lignes de cache qui ne contiennent que des zéros. L'idée est qu'on ajoute, dans la mémoire des tags, un bit de contrôle pour chaque ligne de cache appelé le bit ''null''. Il indique si la ligne de cache ne contient que des zéros. Quand on lit une ligne de cache, la mémoire des tags est accédée et on vérifie le bit ''null'' : s'il vaut 1, on n'accède pas à la mémoire cache de données et un multiplexeur envoie un zéro sur le port de lecture. Le bit ''null'' est fixé lors de l'écriture d'une ligne de cache : elle passe dans un comparateur avec zéro relié à la mémoire des tags. La comparaison avec zéro peut se faire en parallèle de l'écriture ou avant (dans ce cas, on n'écrit pas la ligne de cache dans le cache). Les autres techniques de compression de cache permettent de compresser autre chose que des lignes de cache nulles. L'idée est qu'une ligne de cache physique peut par moment mémoriser plusieurs lignes de caches compressées. Par exemple, prenons un cache dont les lignes de cache font 64 octets. Il est possible de compresser deux lignes de cache pour qu'elles fassent chacune 32 octets, et les stocker dans une seule ligne de cache. Les deux lignes de cache auront des tags différents, mais pointeront sur la même ligne de cache physique. Et cela demande d'utiliser un ''phased cache'' dont la mémoire pour les tags est plus grande que la mémoire pour les données. Il n'y a donc plus une bijection entre tags et ligne de cache, mais une relation surjective. Chose qui n'est possible qu'avec un ''phased cache''. De plus, des bits de contrôles associés à chaque ''tag'' indiquent où se trouvent les lignes de cache compressées dans la ligne de cache : est-ce que c'est les 32 octets de poids fort ou de poids faible ? [[File:Compression de cache.png|centre|vignette|upright=2|Compression de cache]] Il ne semble pas que les techniques de compression de cache soient implémentées sur les processeurs modernes. Aucun n'utilise de compression de cache, à ma connaissance. Il faut dire que les techniques connues sont de mauvais compromis : le temps d'accès du cache augmente beaucoup, le cout en circuit pourrait être utilisé pour un cache non-compressé mais plus grand. Et notons que la compression de cache ne marche que si les données peuvent se compresser. Si ce n'est pas le cas, une partie de la mémoire des tags est inutilisée. Une revue de la littérature académique sur la compression de cache est disponible via ce lien, pour les curieux : * [https://inria.hal.science/hal-03285041 Understanding Cache Compression, par Carvalho et Seznec]. ==L'adressage physique ou logique des caches== Le cache utilise les adresses à lire/écrire pour déterminer s'il a une copie de la donnée en son sein. Mais l’interaction entre caches et mémoire virtuelle donne lieu à un petit problème : l'adresse utilisée est-elle une adresse virtuelle/logique ou physique ? La réponse varie suivant le processeur : certains caches utilisent l'adresse virtuelle, tandis que d'autres prennent l'adresse physique. On parle de cache '''virtuellement tagué''' dans le premier cas et de cache '''physiquement tagué''' dans le second. {| |[[File:Cache tagué virtuellement.png|vignette|Cache tagué virtuellement.]] |[[File:Cache tagué physiquement.png|vignette|Cache tagué physiquement.]] |} ===L'accès à un cache physiquement/virtuellement tagué=== La manière d'accéder à un cache dépend de s'il est virtuellement ou physiquement tagué. Il faut utiliser l'adresse virtuelle pour les premiers, physique pour les seconds. Avec un cache virtuellement tagué, l'adresse logique peut être envoyée directement au cache. La MMU ne traduit les adresses que s'il faut accéder à la mémoire RAM. Ces caches sont donc plus rapides. Avec un cache physiquement tagué, le processeur doit traduire l'adresse logique en adresse physique dans la MMU, avant d'accéder au cache. La traduction d'adresse se fait soit en accédant à une table des pages en mémoire RAM, soit en accédant à un cache spécifiquement dédié à accélérer la traduction d'adresse, la TLB (''Translation Lookaside Buffer''). Dans la quasi-totalité des cas, la traduction d'adresse passe par la TLB, ce qui fait qu'elle est raisonnablement rapide. Toujours est-il que chaque accès au cache demande d'accéder à la TLB et de faire la traduction d'adresse avant d'accéder au cache. L'accès est donc plus lent que sur les caches virtuellement tagués, où les accès sont plus directs. [[File:Virtual and Physical addressing.svg|centre|vignette|upright=2|Cache tagué virtuellement versus physiquement tagué.]] ===Les défauts des caches virtuellement tagués=== Les caches physiquement tagués sont moins rapides que les caches virtuellement adressés. Pourtant, les caches virtuellement tagués sont peu fréquents sur les processeurs modernes. Et la raison est assez intéressante : c'est une question d'adresses homonymes et synonymes. ====Les droits d'accès doivent être vérifiés lors d'un accès au cache==== Un premier problème est que la protection mémoire est compliquée avec de tels caches. Rappelons que certaines portions de mémoire sont accessibles seulement en lecture, ou sont interdites en écriture, sont inexécutables, etc. Ces droits d'accès sont gérés par la MMU, qui vérifie pour chaque accès mémoire que l'accès est autorisé. En bypassant la MMU, l'accès au cache virtuellement tagué ne permet pas de faire ces vérifications. Il est possible de charger une donnée en lecture seule dans le cache, mais d'y faire des accès en écriture pour les accès ultérieurs. Les solutions à cela sont multiples. La première consiste à consulter la MMU en parallèle de l'accès au cache. L'accès au cache est alors réalisé de manière spéculative, et est ensuite confirmé/annulé une fois que la MMU a rendu son verdict. Les performances du cache restent alors les mêmes : l'accès à la MMU se fait en parallèle de l'accès au cache, pas avant. Une autre solution est d'ajouter les droits d'accès en question dans la ligne de cache, dans les bits de contrôle situés après le Tag. Chaque accès au cache récupère ces bits de contrôle et vérifie si l'accès est autorisé. L'inconvénient est que les lignes de cache deviennent plus longues, les droits d'accès sont dupliqués entre MMU et cache. Mais si le budget en transistor suit, ce n'est rien d'insurmontable. ====Les adresses homonymes perturbent la gestion du cache==== Pour rappel, une adresse logique homonyme correspond à plusieurs adresses physiques différentes. Elles surviennent quand chaque programme a son propre espace d'adressage. Dans ce cas, une adresse logique correspondra à une adresse physique différente par programme.Une autre manière de voir les choses est qu'il y a en réalité deux adresses homonymes, qui ont la même valeur, mais appartiennent à des espaces d'adressage différentes. Et c'est cette seconde interprétation que nous allons utiliser. Les caches doivent gérer ces adresses homonymes et faire en sorte que la lecture/écriture d'une adresse homonyme se fasse à la bonne adresse physique, dans la bonne ligne de cache. Et autant un cache physiquement tagué n'a aucun problème avec ça, vu qu'il ne gère que des adresses physiques, autant des problèmes surviennent avec les caches virtuellement tagués. Le problème est que les caches virtuellement tagués doivent faire la différence entre deux adresses homonymes de même valeur. Pour corriger ces problèmes, il existe deux grandes méthodes. La première méthode est simple : '''vider les caches''' en changeant de programme. Leur contenu est rapatrié en mémoire RAM, puis les caches sont remis à zéro. Le vidage du cache recopie les lignes de cache ''dirty'' (modifiées) en RAM, puis efface/invalide tout le cache. C'est à cela que servent les instructions ''clean'' et d'invalidation vues plus haut, elles ont été inventées pour cette situation précise. Lorsque le système d'exploitation déclenche une commutation de contexte, à savoir qu'il change le programme en cours d'exécution, le processeur vide tous les caches du processeur. Les interruptions font la même chose, elles vide tous les caches du processeur. Une seconde méthode numérote chaque programme en cours d'exécution, chaque processus. Le numéro attribué est spécifique à chaque processus, ce qui fait qu'il est appelé un '''identifiant de processus CPU'''. Le processeur mémorise l'identifiant du programme en cours d'exécution dans un registre dédié. L'identifiant de processus CPU est utilisé lors des accès mémoire. Chaque ligne de cache contient le numéro de l'espace d'adressage associé, dans son ''tag''. Lors de chaque accès mémoire, l'ID du registre est comparé à l'ID de la ligne de cache accédée, pour vérifier que l'accès mémoire accède à la bonne donnée. Cette méthode n'est pas très économe en termes de transistors. L'usage d'identifiant de processus CPU est clairement meilleure en termes de performance, les commutations de contexte sont plus rapides. Par contre, le budget en transistor est plus important. Un autre défaut de cette méthode est que l'identifiant de processus est généralement codé sur une dizaine de bits, alors que le système d'exploitation utilise des identifiants de processus beaucoup plus larges, de 32 à 64 bits sur les CPU 32/64 bits. L'OS doit gérer la correspondance entre identifiants de processus CPU et ceux de l'OS. Parfois, pour cette raison, les OS n'utilisent pas toujours ce système d'identifiant de processus CPU. ====Les adresses synonymes perturbent aussi la gestion du cache==== La gestion des adresses synonymes est aussi un gros problème sur les caches virtuellement tagués. Pour rappel, il s'agit du cas où des adresses logiques différentes pointent vers la même adresse physique. Typiquement, quand deux programmes se partagent un morceau de mémoire, ce morceau correspondra à des adresses synonymes dans les deux espaces d'adressage. Mais il arrive que l'on ait des adresses synonymes dans le même espace d'adressage, ce n'est pas si rare ! Autant les adresses synonymes ne posent aucun problème avec les caches physiquement tagués, ce n'est pas le cas avec les caches virtuellement adressés. Sur ces caches, deux adresses logiques synonymes vont tomber dans deux lignes de cache différentes. Corriger ce problème demande d'ajouter des circuits annexes pour détecter les adresses synonymes, qui sont vraiment complexes et ont un cout en termes de performance. Aussi, les caches virtuellement tagués sont très peu utilisés sur les processeurs modernes. ===Les caches virtuellement adressés, mais physiquement tagués=== Si les caches physiquement et virtuellement tagués ont des défauts, il existe un intermédiaire qui est un bon compromis entre ces deux extrêmes. Il s'agit des '''caches virtuellement adressés - physiquement tagués''', aussi appelés '''caches pseudo-virtuels'''. Pour comprendre comment ils fonctionnent, précisons que ces caches sont soit des caches ''direct-mapped'', soit des caches associatifs par voie (composés de plusieurs RAM ''direct-mapped'' accédées en parallèle, plusieurs voies). L'accès à ce genre de cache se fait en deux temps : on accède à un ou plusieurs RAM ''direct-mapped'' et on vérifie ensuite les ''Tags'' pour sélectionner la bonne voie. Sur les caches ''direct-mapped'', on n'a qu'une seule RAM ''direct-mapped''. Sur les caches associatifs, on a plusieurs RAM ''direct-mapped'', appelées des voies, qui sont accédées en parallèle. L'accès se fait donc en deux étapes : adresser les RAM ''direct-mapped'' avec un indice, vérifier les ''tags'' avec le reste de l'adresse. Une autre chose à rappeler est que l'adresse logique est composée de deux parties : un numéro de page logique qui indique dans quel page se situe l'adresse, un décalage/''offset'' qui indique la position de l'adresse dans la page. La traduction d'adresse transforme le numéro de page logique en numéro de page physique, mais laisse le décalage intouché. L'idée est d'utiliser le décalage pour adresser les RAM avec le décalage, tandis que le numéro de page sert de ''tag''. Le décalage est découpé en deux lors de l'accès au cache : les bits de poids fort forment l'indice (l'adresse envoyée à la voie), les bits de poids faible donnent la position de l'adresse dans la ligne de cache. L'idée est d'utiliser un numéro de page physique pour les ''tags'', mais d'adresser les voies avec le décalage logique. Les deux servent à des instants différents : vérification des ''tags'' pour l'adresse physique, accès aux voies pour l'adresse logique. Ainsi, le problème des adresses synonymes ou homonymes est résolu par l'utilisation de l'adresse physique pour les tags. Par contre, l'accès au cache est plus rapide, car on utilise l'adresse logique pour la première étape. Le processeur accède à la TLB et récupère l'adresse physique pendant que l'on adresse les voies, les deux sont faits en parallèle, ce qui fait que tout se passe comme si l'accès à la TLB était gratuit. La TLB étant assez rapide comparé au cache, l'adresse physique est disponible quand on doit faire la comparaison avec les ''tags''. [[File:Virtual - Physical - Pseudo Virtual addressing.svg|centre|vignette|upright=2|Adressage pseudo virtuel des caches.]] Il s'agit d'un excellent compromis entre performance et correction des problèmes des adresses synonymes/homonymes. Tous les caches des processeurs haute performance utilisent cette méthode, au moins pour leurs caches L1. Les caches L2 tendent à utiliser des caches physiquement adressés, pour lesquels la latence d'accès est suffisante pour qu'on accède à la TLB en amont. La raison est assez simple à expliquer, elle provient d'une contrainte assez précise sur le calcul de l'indice. La conséquence est qu'un cache ''direct-mapped'' ne peut pas dépasser la taille d'une page, soit 4 kibioctets sur les ordinateurs actuels. Sur les caches associatifs, on peut dépasser cette limite en augmentant le nombre de voies, mais la taille maximale d'une voie reste celle d'une page. Cette contrainte n'est pas trop grave sur les caches de petite taille, dont les caches L1. La plupart d'entre eux ont trouvé un compromis idéal avec moins d'une dizaine de voies par cache, chacun de 4 kibioctets, ce qui donne des caches allant de 16 à 64 kibioctets, soit entre 4 et 16 voies. Par contre, un cache de grande taille doit utiliser un grand nombre de voies, ce qui est peu pratique. Aussi, cette technique de caches pseudo-virtuels n'est pas toujours appliquée sur les caches L2, qui sont physiquement adressés. Il faut dire qu'on accède au cache L2 lors d'un défaut dans le cache L1, et l'adresse physique est disponible à ce moment-là, elle a déjà été récupérée lors de l'accès au cache L1. On peut donc l'utiliser pour adresser le cache L2 sans perte de performance. ==Le remplacement des lignes de cache== Lorsqu'un cache est rempli et qu'on charge une nouvelle donnée dedans, il faut faire de la place pour cette dernière. Dans le cas d'un cache directement adressé, il n'y a rien à faire vu que la ligne de cache à évincer est déterminée lors de la conception du cache. Mais pour les autres caches, la donnée peut aller dans n'importe quelle ligne ou voie. Or, le choix des données à rapatrier en RAM doit être le plus judicieux possible : on doit virer de préférence des données inutiles. Rapatrier une donnée qui sera surement utilisée sous peu est inutile, et il vaudrait mieux supprimer des données qui ne serviront plus ou alors dans longtemps. Il existe différents algorithmes spécialement dédiés à résoudre ce problème efficacement, directement câblés dans les unités de gestion du cache. Certains sont vraiment très complexes, aussi je vais vous présenter quelques algorithmes particulièrement simples. Mais avant de voir ces algorithmes, il faut absolument que je vous parle d'une chose très importante. Quel que soit l'algorithme en question, il choisit la ligne de cache à évincer et recopie son contenu dans la RAM. Ce qui demande d'identifier et de sélectionner une ligne de cache parmi toutes les autres. Pour cela, le circuit de remplacement attribue une adresse chaque ligne de cache ! Vous avez bien vu : chaque ligne de cache est numérotée par une adresse, interne au cache. ===Le remplacement aléatoire=== Premier algorithme : la donnée effacée du cache est choisie au hasard ! C'est contre-intuitif, mais cet algorithme donne des résultats assez honorables, en plus d'utiliser très peu de portes logiques (un générateur de nombres pseudo-aléatoire est un circuit assez simple). Généralement, les défauts de cache sont séparés par un nombre assez important et irrégulier de cycles d'horloge. Dans ces conditions, cette technique donne un bon résultat. ===FIFO : first in, first out=== Avec l'algorithme FIFO, la donnée effacée du cache est la plus ancienne, celle chargée dans le cache avant les autres. Cet algorithme est très simple à implémenter en circuit, concevoir une mémoire de type FIFO n'étant pas très compliqué, comme on l’a vu dans le chapitre dédié à ce type de mémoires. Et on peut dire que dans le cas d'un cache, l'implémentation est encore plus simple et se contente d'un seul registre/compteur. Typiquement, il suffit d'ajouter un registre qui mémorise où se situe la donnée la plus récente. Toute insertion d'une nouvelle donnée se fait à l'adresse suivante, ce qui demande juste d'incrémenter le registre avant d'utiliser son contenu pour l'accès mémoire. [[File:Algorithme FIFO de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme FIFO de remplacement des lignes de cache.]] Cet algorithme possède une petite particularité sur les caches associatifs par voie : en augmentant le nombre d'ensembles, les performances peuvent se dégrader : c'est ce qu'on appelle l''''anomalie de Bélády'''. ===MRU : most recently used=== Avec l'algorithme MRU, la donnée remplacée est celle qui a été utilisée le plus récemment. Cet algorithme s'implémente simplement avec un registre, dans lequel on place le numéro de la dernière ligne de cache utilisée. Cet algorithme de remplacement est très utile quand un programme traverse des tableaux du premier élément jusqu'au dernier : les données du tableau sont rarement réutilisées, rendant le cache inutile. Il est prouvé que dans ces conditions, l'algorithme MRU est optimal. Mais dans toutes les autres conditions, cet algorithme a des performances assez misérables. ===LFU : least frequently used=== Avec l'algorithme LFU, la donnée supprimée est celle qui est utilisée le moins fréquemment. Cet algorithme s'implémente en associant un compteur à chaque ligne de cache, qui est incrémenté à chaque accès mémoire. La ligne la moins récemment utilisée est celle dont le compteur associé a la plus petite valeur. Implémenter cet algorithme prend pas mal de transistors, car il faut rajouter autant de compteurs qu'il y a de lignes de cache, en plus d'un circuit pour comparer les compteurs et d'un encodeur. [[File:Algorithme LFU de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme LFU de remplacement des lignes de cache]] ===LRU : least recently used=== Avec l'algorithme LRU, la donnée remplacée est celle qui a été utilisée le moins récemment. Cet algorithme se base sur le principe de localité temporelle, qui stipule qu'une donnée accédée récemment a de fortes chances d'être réutilisée dans un futur proche. Et inversement, la donnée la moins récemment utilisée du cache est celle qui a le plus de chance de ne servir à rien dans le futur. Autant la supprimer en priorité pour faire de la place à des données potentiellement utiles. Implémenter l'algorithme LRU peut se faire de différentes manières, qui ont pour point commun d'enregistrer les accès au cache pour en déduire la ligne la moins récemment accédée. La manière la plus simple demande d'utiliser un compteur pour chaque ligne de mémoire cache, un peu comme le LFU. La différence avec le LFU est que le compteur n'est pas incrémenté lors d'un accès mémoire. À la place, ce compteur est incrémenté régulièrement, chaque incrémentation ayant lieu en même temps pour tous les compteurs. Quand un bloc est chargé dans le cache, ce compteur est mis à zéro. Quand une ligne de cache doit être remplacée, un circuit va vérifier la valeur de tous les compteurs : la ligne LRU (la moins récemment utilisée), est celle dont le compteur a la valeur la plus haute. Le circuit est composé d'un paquet de comparateurs, et d'un encodeur, comme pour l'agorithme LFU. ===Les approximations du LRU=== Implémenter le LRU demande un nombre de transistors proportionnel au carré du nombre de lignes de cache. Autant dire que le LRU devient impraticable sur de gros caches. Ce qui fait que les processeurs modernes implémentent des variantes du LRU, moins couteuses en transistors, qui donnent un résultat approximativement semblable au LRU. En clair, ils ne sélectionnent pas toujours la ligne de cache la moins récemment utilisée, mais une ligne de cache parmi les moins récemment utilisées. Ce n'est pas un problème si grave que cela car les lignes les moins récemment utilisées ont toutes assez peu de chance d'être utilisées dans le futur. Entre choisir de remplacer une ligne qui a 0,5 % de chances d'être utilisée dans le futur et une autre qui a une chance de seulement 1 %, la différence est négligeable en termes de taux de succès. Mais les gains en termes de circuits ou de temps d'accès au cache de ces algorithmes sont très intéressants. L'algorithme le plus simple consiste à couper le cache (ou chaque voie s'il est associatif) en plusieurs sections. L'algorithme détermine la section la moins récemment utilisée, avant de choisir aléatoirement une ligne de cache dans cette section. Pour implémenter cet algorithme, il nous suffit d'un registre qui mémorise le morceau le moins récemment utilisé, et d'un circuit qui choisit aléatoirement une ligne de cache. Cette technique s'adapte particulièrement bien avec des caches associatifs à voies : il suffit d'utiliser autant de morceaux que de voies. Autre algorithme, un peu plus efficace : le '''pseudo-LRU de type M'''. Cet algorithme attribue un bit à chaque ligne de cache, bit qui sert à indiquer de façon approximative si la ligne de cache associée est une candidate pour un remplacement ou non. Il vaut 1 si la ligne n'est pas une candidate pour un remplacement et zéro sinon. Le bit est mis à 1 lorsque la ligne de cache associée est lue ou écrite. Évidemment, au fil du temps, toutes les lignes du cache finiront par avoir leur bit à 1. Lorsque cela arrive, l'algorithme remet tous les bits à zéro, sauf pour la dernière ligne de cache accédée. L'idée derrière cet algorithme est d'encercler la ligne de cache la moins récemment utilisée au fur et à mesure des accès. L'encerclement commence lorsque l'on remet tous les bits associés aux lignes de cache à 0, sauf pour la ligne accédée en dernier. Au fur et à mesure des accès, l'étau se resserre autour de la ligne de cache la moins récemment utilisée. Après un nombre suffisant d'accès, l'algorithme donne une estimation particulièrement fiable. Et comme les remplacements de lignes de cache sont rares comparés aux accès aux lignes, cet algorithme finit par donner une bonne estimation avant qu'on ait besoin d'effectuer un remplacement. Le dernier algorithme d'approximation, le '''PLURt''', se base sur ce qu'on appelle un arbre de décision. Il a besoin de n − 1 bits pour déterminer la ligne LRU. Ces bits doivent être organisés en arbre, comme illustré plus bas. Chacun de ces bits sert à dire : le LRU est à ma droite ou à ma gauche : il est à gauche si je vaux 0, et à droite si je vaux 1. Trouver le LRU se fait en traversant cet arbre, et en interprétant les bits un par un. Au fur et à mesure des lectures, les bits sont mis à jour dans cet arbre, et pointent plus ou moins bien sur le LRU. La mise à jour des bits s'effectue lors des lectures et écritures : quand une ligne est lue ou écrite, elle n'est pas la ligne LRU. Pour l'indiquer, les bits à 1 qui pointent vers la ligne de cache sont mis à 0 lors de la lecture ou écriture. {| |[[File:Organisation des bits avec l'algorithme PLURt.jpg|vignette|Organisation des bits avec l'algorithme PLURt.]] |[[File:Ligne de cache pointée par les bits de l'algorithme.png|vignette|Ligne de cache pointée par les bits de l'algorithme.]] |} ===LRU amélioré=== L'algorithme LRU, ainsi que ses variantes approximatives, sont très efficaces tant que le programme respecte relativement bien la localité temporelle. Par contre, Le LRU se comporte assez mal dans les circonstances ou la localité temporelle est mauvaise mais où la localité spatiale est respectée, le cas le plus emblématique étant le parcours d'un tableau. Pour résoudre ce problème, des variantes du LRU existent. Une variante très connue, l''''algorithme 2Q''', utilise deux caches : un cache FIFO pour les données accédées une seule fois et un second cache LRU. Évidemment, les données lues une seconde fois sont migrées du cache FIFO vers le cache LRU, ce qui n'est pas très pratique. Les processeurs n'utilisent donc pas cette technique, mais celle-ci est utilisée dans les caches de disque dur. D'autres variantes du LRU combinent plusieurs algorithmes à la fois et vont choisir lequel de ces algorithmes est le plus adapté à la situation. Notre cache pourra ainsi détecter s’il vaut mieux utiliser du MRU, du LRU, ou du LFU suivant la situation. ==Les écritures dans le cache : gestion et optimisations== Les écritures se font à une adresse mémoire bien précise, qui peut ou non être chargée dans le cache. Si la donnée à écrire est chargée dans le cache, elle est modifiée directement dans le cache, mais elle ne l'est pas forcément en mémoire RAM. Suivant le processeur, les écritures sont ou non propagées en mémoire RAM. Il existe deux stratégies d'écritures, appelées respectivement le ''write-back'' et le ''write-through''. Avec un cache ''write-back'', si la donnée à mettre à jour est présente dans le cache, on écrit dans celui-ci sans écrire dans la mémoire RAM. Dans ces conditions, une donnée n'est enregistrée en mémoire que si celle-ci quitte le cache, ce qui évite de nombreuses écritures mémoires inutiles. [[File:Cache write-through.png|centre|vignette|upright=2|Cache write-through.]] Avec les caches '''Write-Through''', toute écriture dans le cache est propagée en RAM. Cette stratégie augmente le nombre d'écritures dans la mémoire RAM, ce qui peut saturer le bus reliant le processeur à la mémoire. Les performances de ces caches sont donc légèrement moins bonnes que pour les caches ''write back''. Par contre, ils sont utiles dans les architectures avec plusieurs processeurs, comme nous le verrons dans les chapitres sur les architectures multiprocesseurs. [[File:Cache write-back.png|centre|vignette|upright=2|Cache write-back.]] ===Les caches ''Write-through''=== Sans optimisation particulière, on ne peut écrire dans un cache ''write-through'' pendant qu'une écriture en RAM a lieu en même temps : cela forcerait à effectuer deux écritures simultanées, en comptant celle imposée par l'écriture dans le cache. Pour éviter cela, certains caches ''write-through'' intègrent un '''tampon d’écriture''', qui sert de file d'attente pour les écritures en RAM. C'est une mémoire FIFO dans laquelle on place temporairement les données à écrire en RAM, où elles attendent en attendant que la RAM soit libre. Grâce à lui, le processeur peut écrire dans un cache même si d'autres écritures sont en attente dans le tampon d'écriture. Par souci d'efficacité, des écritures à la même adresse en attente dans le tampon d’écriture sont fusionnées en une seule. Cela fait un peu de place dans le tampon d’écriture, et lui permet d'accumuler plus d'écritures avant de devoir bloquer le cache. Il est aussi possible de fusionner des écritures à adresses consécutives de la mémoire en une seule écriture en rafales. Dans les deux cas, on parle de '''combinaison d'écriture'''. Mais la technique du tampon d'écriture a cependant un léger défaut qui se manifeste dans une situation bien précise : quand le processeur veut lire une donnée en attente dans le tampon d’écriture. La première manière de gérer cette situation est de mettre en attente la lecture tant que la donnée n'a pas été écrite en mémoire RAM. On peut aussi lire la donnée directement dans le tampon d'écriture, cette optimisation portant le nom de '''''store-to-load forwading'''''. Dans tous les cas, il faut détecter le cas où une lecture accède à une donnée dans le tampon d'écriture. À chaque lecture, l'adresse à lire est envoyée au tampon d'écriture, qui vérifie si une écriture en attente se fait à cette adresse. Pour cela, le tampon d’écriture doit être un cache, dont chaque entrée mémorise une écriture. Chaque ligne de cache contient la donnée à écrire, et le tag de la ligne de cache contient l'adresse où écrire la donnée. Notons que cache d'écriture a une politique de remplacement de type FIFO, le tampon d'écriture non-optimisé étant une mémoire FIFO. ===Les caches ''Write-back''=== Les caches ''write-back'' ont beau avoir des performances supérieures à celles des caches ''write-through'', il existe des optimisations qui permettent d'améliorer leurs performances. Ces optimisations consistent à ajouter des caches spécialisés à côté du cache proprement dit. Ces caches permettent de mémoriser des données qui sont éliminées du cache par les algorithmes de remplacement de ligne cache, sans pour autant faire une écriture en RAM. En suivant la procédure habituelle de remplacement des lignes de cache, on doit rapatrier la ligne en RAM avant d'en charger une nouvelle. On peut améliorer la situation en faisant l'inverse : on charge la nouvelle ligne pendant que l'ancienne donnée est rapatriée en RAM. Ainsi, la nouvelle ligne est disponible plus tôt pour le processeur, diminuant son temps d'attente. Pour implémenter cette technique, on doit mémoriser l'ancienne ligne de cache temporairement dans un '''cache d’éviction''' (ou ''write-back buffer''). [[File:Cache d’éviction.png|centre|vignette|upright=2|Cache d’éviction]] Les caches directement adressés ou associatifs par voie possèdent aussi un tampon d’écriture amélioré. Pour limiter les défauts par conflit de ces caches, des scientifiques ont eu l'idée d'insérer un cache pour stocker les données virées du cache. En faisant ainsi, si une donnée est virée du cache, on peut alors la retrouver dans ce cache spécialisé. Ce cache s'appelle le '''cache de victime'''. Ce cache de victime est géré par un algorithme de suppression des lignes de cache de type FIFO. Petit détail : ce cache utilise un tag légèrement plus long que celui du cache directement adressé au-dessus de lui. L'index de la ligne de cache doit en effet être contenu dans le tag du cache de victime, pour bien distinguer deux adresses différentes, qui iraient dans la même ligne du cache juste au-dessus. [[File:Victim Cache Implementation Example.svg|centre|vignette|upright=1|Cache de victime.]] ===La configuration du fonctionnement du cache=== Sur de nombreux processeurs, il est possible de configurer la mémoire cache pour qu'elle fonctionne soit en mode ''write-back'', soit en mode ''write-through''. Pour cela, les processeurs modernes incorporent des '''registres de configuration du cache'''. Le terme ''registre de configuration du cache'' est assez transparent et indique bien quel est leur rôle. Ils configurent comment le cache est utilisé et permettent notamment de configurer le cache pour dire s'il doit fonctionner en mode ''write-back'' ou ''write-through''. Ils permettent aussi d'activer ou de désactiver la combinaison sur écriture. Les registres en question sont configurés soit par le BIOS, soit par le système d'exploitation. Ce sont des registres protégés, que les applications ne peuvent pas configurer, elles n'en ont pas le droit. Typiquement, ils ne sont accessibles en écriture qu'en mode noyau. Sur les processeurs x86, les registres de configuration du cache sont appelés des '''''Memory type range registers''''' (''MTRRs''). Les MTRRs sont assez nombreux, et il y a notamment une différence entre mode réel et protégé. Si vous vous souvenez des chapitres sur le mode d'adressage et la mémoire virtuelle, vous vous souvenez que les processeurs x86 incorporent plusieurs modes de fonctionnement. En mode réel, le processeur ne peut adresser qu'un mébioctet de RAM, avec un système de segmentation particulier. En mode protégé, le processeur peut adresser toute la mémoire et la segmentation fonctionne différemment, quand elle n'est pas simplement désactivée. Les MTRRs sont séparés en deux : ceux pour le mode réel, ceux pour le mode protégé. Les MTRRs fixes sont ceux qui configurent le cache en mode réel, ils étaient utilisés pour gérer l'accès au BIOS, à la mémoire VGA de la carte graphique, et quelques autres accès aux entrées-sorties basiques gérées nativement par le BIOS. Pour le mode protégé, les processeurs au-delà du 386 incorporent des MTRRs variables, qui servent pour les autres entrées-sorties en général, notamment les périphériques PCI, la mémoire vidéo de la carte graphique, et j'en passe. De nos jours, les registres de configuration du cache sont désuets et cette fonctionnalité est gérée directement par la mémoire virtuelle. La table des pages contient, pour chaque page mémoire, des bits de contrôle qui disent si la page mémoire est cacheable ou non. Le contournement de cache est alors géré par le système de mémoire virtuelle, le cache de TLB et tout ce qui va avec. ===L’allocation sur écriture=== Que faire quand une écriture modifie une donnée qui n'est pas dans le cache ? Doit-on écrire la donnée dans le cache, ou non ? Si la donnée est écrite dans le cache, on dit que le cache fait une '''allocation sur l'écriture''' (ou ''write-allocate''). Certains caches effectuent une telle allocation sur écriture, mais d'autres ne le font pas ou du moins pas systématiquement. L’allocation sur écriture peut se décliner en deux sous-catégories : le '''chargement à la demande''' et l''''écriture immédiate'''. Dans le premier cas, on charge la donnée à modifier dans le cache, et on la remplace avec la donnée écrite. Dans l'écriture immédiate, l'écriture a lieu directement dans le cache et la donnée à modifier n'est pas chargée dans le cache. Évidemment, seule une portion de la ligne de cache contient la donnée écrite (valide), et le reste contient des données invalides. Le cache doit savoir quelles sont les portions du cache qui sont valides : cela demande d'utiliser un ''sector cache''. [[File:Write-back with write-allocation.svg|centre|vignette|upright=2|Cache Write-back avec allocation sur écriture.]] Sans allocation sur écriture, l'écriture est transférée directement aux niveaux de cache inférieurs ou à la mémoire si la donnée à modifier n'est pas dans le cache. Certains caches de ce genre utilisent une petite optimisation : lors de toute écriture, ils supposent que l'écriture donnera un succès de cache. Si c'est le cas, la ligne de cache qui contient la donnée est mise à jour avec la donnée à écrire. Mais si ce n'est pas le cas, la ligne de cache est invalidée, et l'écriture est transférée directement à la mémoire ou aux niveaux de cache inférieurs. [[File:Write-through with no-write-allocation.svg|centre|vignette|upright=2|Cache Write-through sans allocation sur écriture.]] ===La cohérence des caches=== Il arrive parfois que la mémoire d'un ordinateur soit mise à jour, sans que les modifications soient répercutées dans les mémoires cache. Dans ce cas, le cache contient une donnée périmée. Or, un processeur doit toujours éviter de se retrouver avec une donnée périmée et doit toujours avoir la valeur correcte dans ses caches : cela s'appelle la '''cohérence des caches'''. Il est possible de se retrouver avec des valeurs périmées dans le cache sur les ordinateurs avec plusieurs processeurs, ou si un périphérique écrit en RAM, les modifications ne sont pas répercutées automatiquement dans les mémoires cache. Pour résoudre ce problème, on peut interdire de charger dans le cache des données stockées dans les zones de la mémoire dédiées aux périphériques. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires cache. Autre solution : utiliser le fait que les périphériques déclenchent une interruption matérielle pour laisser le contrôleur DMA accéder à la mémoire. Dans ce cas, il suffit de vider les caches à chaque interruption matérielle. Le processeur peut le faire automatiquement, ou fournir des instructions pour. ==Le ''cache bypassing'' : contourner le cache== Dans certaines situations, le cache n'est pas utilisé pour certains accès mémoire. Diverses techniques permettent en effet d'effectuer des accès mémoire qui contournent le cache, qui ne passent pas par le cache. Ils sont utilisés quand l'accès en cache fait que des instructions normales ne fonctionnent pas. Par exemple, de tels accès directs à la RAM sont notamment utilisés pour l'implémentation d'instructions atomiques, une classe d'instructions spécifiques utilisées sur les processeurs multicœurs, dont nous parlerons dans plusieurs chapitres. Mais ils sont aussi utilisés pour l'accès aux périphériques, ce que nous allons voir maintenant. ===Accéder aux périphériques demande de contourner le cache=== Pour rappel, un périphérique (au sens d'entrée-sortie) contient des registres d’interfaçage qui ont une adresse au même titre que les cases mémoire. Un périphérique peut à tout instant modifier ses registres d’interfaçage, ce qui se répercute automatiquement dans l'espace d'adressage, mais rien de tout cela n'est transmis au cache. Si les accès aux périphériques passaient par l'intermédiaire du cache, on aurait droit à des problèmes. On aurait encore une fois droit à des problèmes de cohérence des caches. Le problème est géré différemment suivant que l'on utilise un espace d'adressage séparé ou des entrées-sorties mappées en mémoire. La solution est que les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache. Cela demande d'adapter le cache et le processeur. L'implémentation exacte dépend de comment sont adressés les périphériques. Pour rappel, il y a deux solutions pour adresser les périphériques : soit les périphériques disposent d'un espace d'adressage séparé de celui de la mémoire, soit il y un espace d'adressage unique partagé entre processeur et mémoire. Les deux cas donnent des solutions différentes. Avec un espace d'adressage séparé, l'espace d'adressage des périphériques n'est pas caché : aucun accès dans cet espace d'adressage ne passe par le cache. La mémoire cache n'est utilisée que pour l'espace d'adressage des mémoires, rien d'autre. C'est de loin le cas le plus simple : il suffit de concevoir le processeur pour. Il dispose d'instructions séparées pour les accès aux registres d’interfaçage et à la RAM/ROM, les premières ne passent pas par le cache, les autres si. Avec des entrées-sorties mappées en mémoire, la même solution est utilisée, mais dans une version un peu différente. Là encore, les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache, si on veut qu'ils marchent comme ils le doivent. Cela demande d'adapter le cache et le matériel pour que accès aux périphériques mappés en mémoire contournent le cache. Des adresses, voire des zones entières de la mémoire, sont marquées comme étant non-cachables. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires caches. Là encore, le processeur doit être prévu pour : on doit pouvoir le configurer de manière à marquer certaines zones de la RAM comme non-cacheable. Reste qu'il faut marquer des régions de la RAM comme non-cacheable. Pour cela, on améliore les registres de configuration du cache, vus plus haut, afin qu'ils permettent de configurer certaines portions de la RAM pour préciser qu'elles ne doivent pas être mises en cache, qu'il faut activer le contournement de cache pour celles-ci. ===Contourner le cache pour des raisons de performance=== Il arrive que des données avec une faible localité soient chargées dans le cache inutilement. Or, il vaut mieux que ces données transitent directement entre le processeur et la mémoire, sans passer par l'intermédiaire du cache. Pour cela, le processeur peut fournir des instructions d'accès mémoire qui ne passent pas par le cache, à côté d'instructions normales. De telle instructions sont appelées des '''instructions mémoire non-temporelles'''. Non-temporelle, dans le sens : pas de localité temporelle (c.a.d que les données ne seront pas réutilisées plus tard). Mais il existe aussi des techniques matérielles, où le cache détecte à l'exécution les lectures qui gagnent à contourner le cache. La dernière méthode demande d'identifier les instructions à l'origine des défauts de cache, le processeur accédant directement à la RAM quand une telle instruction est détectée. Si une instruction d'accès mémoire fait trop de défauts de cache, c'est signe qu'elle gagne à contourner le cache. L'idée est de mémoriser, pour chaque instruction d'accès mémoire, un historique de ses défauts de cache. Il existe plusieurs méthodes pour cela, mais toutes demandent d'ajouter de quoi mémoriser l'historique des défauts de cache des instructions. L'historique est mémorisé dans une mémoire appelée la '''table d’historique des défauts de lecture''' (''load miss history table''), qui est souvent un cache. L'historique en question est, dans sa version la plus simple, un compteur de quelques bits incrémenté à chaque succès de cache et décrémenté à chaque défaut de cache, qui indique si l'instruction a en moyenne fait plus de défauts ou de succès de cache. La table associe le ''program counter'' d'une instruction mémoire à cet historique. À la première exécution d'une instruction d'accès mémoire, une entrée de cette table est réservée pour l'instruction. Lors des accès ultérieurs, le processeur récupérer les informations associées et décide s'il faut contourner le cache ou non. ==La hiérarchie mémoire des caches== [[File:Cache Hierarchy.png|vignette|Hiérarchie de caches]] On pourrait croire qu'un seul cache est largement suffisant pour compenser la lenteur de la mémoire. Hélas, les processeurs sont devenus tellement rapides que les caches sont eux-mêmes très lents ! Pour rappel, plus une mémoire peut contenir de données, plus elle est lente. Et les caches ne sont pas épargnés. Si on devait utiliser un seul cache, celui-ci serait très gros et donc trop lent. La situation qu'on cherche à éviter avec la mémoire RAM revient de plus belle. Même problème, même solution : si on a décidé de diviser la mémoire principale en plusieurs mémoires de taille et de vitesse différentes, on peut bien faire la même chose avec la mémoire cache. Depuis environ une vingtaine d'années, un processeur contient plusieurs caches de capacités très différentes : les caches L1, L2 et parfois un cache L3. Certains de ces caches sont petits, mais très rapides : c'est ceux auxquels on va accéder en priorité. Viennent ensuite d'autres caches, de taille variable, mais plus lents. Les processeurs ont donc une hiérarchie de caches qui se fait de plus en plus complexe avec le temps. Cette hiérarchie est composée de plusieurs niveaux de cache, qui vont des niveaux inférieurs proches de la mémoire RAM à des niveaux supérieurs proches du processeur. Plus on monte vers les niveaux supérieurs, plus les caches sont petits et rapides. Un accès mémoire dans une hiérarchie de cache fonctionne comme suit : on commence par vérifier si la donnée recherchée est dans le cache le plus rapide, à savoir le cache L1. Si c'est le cas,n on la charge depuis ce cache directement. Si elle n’y est pas, on vérifie si elle est dans le cache de niveau supérieur, le cache L2. Et rebelote ! Si elle n'y est pas, on vérifie le cache du niveau supérieur. Et on répète cette opération, jusqu’à avoir vérifié tous les caches. Si la donnée n'est dans aucun cache, on doit alors aller chercher la donnée en mémoire. [[File:Hiérarchie de caches.png|centre|vignette|upright=2|Hiérarchie de caches]] Il y a des différences assez notables entre chaque niveau de cache. Par exemple, les différents niveaux de cache n'ont pas forcément les mêmes politiques de remplacement des lignes de cache. Le cache L1 a généralement une politique de remplacement simple, très rapide, mais peu efficace. De même, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Le cache le plus proche de la mémoire est appelé le '''cache de dernier niveau''', ''Last Level Cache'' en anglais. Il a parfois des caractéristiques totalement différentes des autres caches. Par exemple, sur les processeurs multicoeurs, le cache L3 n'est techniquement pas dans le cœur, mais fait partie d'un ensemble de circuits reliés, comme le contrôleur mémoire ou l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc. ===Les caches exclusifs et inclusifs=== Notons que du point de vue de cette vérification, il faut distinguer les caches inclusifs et exclusifs. Avec les caches inclusifs, si une donnée est présente dans un cache, alors elle est présente dans les caches des niveaux inférieurs, ce qui implique l'existence de données en doublon dans plusieurs niveaux de cache. À l'opposé, les caches exclusifs font que toute donnée est présente dans un seul cache, pas les autres. Il existe aussi des caches qui ne sont ni inclusifs, ni exclusifs. Sur ces caches, chaque niveau de cache gère lui-même ses données, sans se préoccuper du contenu des autres caches. Pas besoin de mettre à jour les niveaux de cache antérieurs en cas de mise à jour de son contenu, ou en cas d'éviction d'une ligne de cache. La conception de tels caches est bien plus simple. Dans les '''caches exclusifs''', le contenu d'un cache n'est pas recopié dans le cache de niveau inférieur. Il n'y a pas de donnée en double et on utilise 100 % de la capacité du cache, ce qui améliore le taux de succès. Par contre, le temps d'accès est un peu plus long. La raison est que si une donnée n'est pas dans le cache L1, on doit vérifier l'intégralité du cache L2, puis du cache L3. De plus, assurer qu'une donnée n'est présente que dans un seul cache nécessite aux différents niveaux de caches de communiquer entre eux pour garantir que l'on a pas de copies en trop d'une ligne de cache, ce qui peut prendre du temps. [[File:Caches exclusifs.png|centre|vignette|upright=2|Caches exclusifs]] Dans le cas des '''caches inclusifs''', le contenu d'un cache est recopié dans les caches de niveau inférieur. Par exemple, le cache L1 est recopié dans le cache L2 et éventuellement dans le cache L3. Ce genre de cache a un avantage : le temps d'accès à une donnée est plus faible. La raison est qu'il ne faut pas vérifier tout un cache, mais seulement la partie qui ne contient pas de donnée en doublon. Par exemple, si la donnée voulue n'est pas dans le cache L1, on n'est pas obligé de vérifier la partie du cache L2 qui contient la copie du L1. Ainsi, pas besoin de vérifier certaines portions du cache, ce qui est plus rapide et permet de simplifier les circuits de vérification. En contrepartie, l'inclusion fait que qu'une partie du cache contient des copies inutiles, comme si le cache était plus petit. De plus, maintenir l'inclusion est compliqué et demande des circuits en plus et/ou des échanges de données entre caches. [[File:Caches inclusifs.png|centre|vignette|upright=2|Caches inclusifs]] Maintenir l'inclusion demande de respecter des contraintes assez fortes, ce qui ne se fait pas facilement. Premièrement, toute donnée chargée dans un cache doit aussi l'être dans les caches de niveau inférieur. Ensuite, quand une donnée est présente dans un cache, elle doit être maintenue dans les niveaux de cache inférieurs. De plus, toute donnée effacée d'un cache doit être effacée des niveaux de cache supérieurs : si une donnée quitte le cache L2, elle doit être effacée du L1. Ces trois contraintes posent des problèmes si chaque cache décide du remplacement des lignes de cache en utilisant un algorithme comme LRU, LFU, MRU, ou autre, qui utilise l'historique des accès. En effet, dans ce cas, le cache décide de remplacer les lignes de cache selon l'historique des accès, historique qui varie suivant chaque niveau de cache. Par exemple, une donnée rarement utilisée dans le L2 peut parfaitement être très fréquemment utilisée dans le L1 : la donnée sera alors remplacée dans le L2, mais sera maintenue dans le L1. On observe aussi des problèmes quand il existe plusieurs caches à un seul niveau : chaque cache peut remplacer les lignes de cache d'une manière indépendante des autres caches du même niveau, donnant lieu au même type de problème. Pour maintenir l'inclusion, les caches doivent se transmettre des informations qui permettent de maintenir l'inclusion. Par exemple, les caches de niveaux inférieurs doivent prévenir les niveaux de cache supérieurs quand ils remplacent une ligne de cache. De plus, toute mise à jour dans un cache doit être répercutée dans les niveaux de cache inférieurs et/ou supérieurs. On doit donc transférer des informations de mise à jour entre les différents niveaux de cache. Généralement, le contenu des caches d'instruction n'est pas inclus dans les caches de niveau inférieurs, afin d'éviter que les instructions et les données se marchent sur les pieds. Enfin, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Dans ce cas, l'inclusion est plus difficile à maintenir, pour des raisons assez techniques. ===Les caches eDRAM, sur la carte mère et autres=== D'ordinaire, les mémoires caches sont intégrées au processeur, à savoir que cache et CPU sont dans le même circuit imprimé. Les caches sont donc fabriqués avec de la SRAM, seule forme de mémoire qu'on peut implémenter dans un circuit intégré. Intégrer tous les caches dans le processeur est une solution et efficace. Mais certains processeurs ont procédé autrement. [[File:Cache-on-a-stick module.jpg|vignette|Cache-on-a-stick module]] Des processeurs assez anciens incorporaient un cache L1 dans le processeur, mais plaçaient un cache L2 sur la carte mère. Le cache était clippé sur un connecteur sur la carte mère, un peu comme le sont les barrettes de mémoire. On parlait alors de '''''Cache on a stick''''' (COAST). On aurait pu s'attendre à ce que de tels caches soient en DRAM, vu qu'ils sont placés sur des barrettes de RAM, mais la ressemblance avec la mémoire RAM principale s'arrête là. Le cache était fabriqué en mémoire SRAM, même s'il est en théorie possible de faire de tels caches avec de la DRAM. Les premiers processeurs avec un cache faisaient ainsi, au début des années 90. Il a été introduiot sur les processeurs Motorola, et a été utilisé sur les IBM PC et les Macintosh de l'époque. Les ordinateurs Macintosh utilisaient de tels caches, pour la pluaprt des modèles. Pour ce qui est des PC, les premiers processeurs x86 faisaient pareil, notamment les processeurs Intel. Le 486, le Pentium et le Pentium 2 utilisaient des ''Cache on a stick''. L'avantage est que cela permettait de mettre plus de cache, à une époque où les circuits étaient limités en transistors. De plus, cela permettait au consommateur de choisir quelle quantité de cache il voulait, selon ses finances. Il était possible de laisser le processeur fonctionner soit sans mémoire cache, soit avec un cache de 256 Kibioctets, de 512 Kibioctets, etc. Il était possible d'upgrader le cache si besoin. Pour les CPU Intel, le cache était connecté sur le bus système, au même titre que la mémoire RAM et les entrées-sorties. Il faut dire que les processeurs de l'époque utilisaient un bus système et n'avaient pas de bus mémoire dédié. Mais en théorie, rien n’empêche de connecter le cache sur un bus mémoire dédié. Toujours est-il que les lectures et écritures étaient propagées à la fois dans le cache et la RAM. Les écritures se faisaient dans les deux, systématiquement dans la RAM, mais aussi dans le cache en cas de succès de cache. Les lectures étaient servies soit par le cache en cas de succès de cache, soit par la RAM en cas de défaut de cache. Si le cache répondait en premier, la transaction sur le bus se terminait précocement et l'accès en RAM était abandonné. [[File:Intel486 Иерархия памяти.png|centre|vignette|upright=2.5|Intel486 : le cache était connecté sur le bus système.]] À l'inverse, certains processeurs possédaient un cache fabriqué en mémoire DRAM, et plus précisément avec de la mémoire eDRAM. Le cache n'était pas intégré dans le même circuit imprimé que le processeur, mais profitait d'une architecture en ''chiplet''. Pour rappel, cela veut dire que le processeur est en réalité composé de plusieurs circuits intégré séparés, mais interconnectés et soudés sur un même PCB carré. Avec un cache en eDRAM, le cache avait son propre circuit intégré, séparé du circuit intégré du processeur ou du circuit intégré pour le contrôleur mémoire/IO. Un exemple est celui du cache des processeurs Intel de microarchitecture Broadwell, vus dans ce chapitre dans la section sur les caches splittés. Les tags étaient intégrés dans le circuit intégré du processeur, mais les données étaient mémorisées dans une puce d'eDRAM séparée. La puce eDRAM correspondait en réalité à une DRAM adressable qui servait de DRAM pour les données et mémorisaient les voies du cache. ==Les caches adressés par somme et hashés== Les caches adressés par somme sont optimisés pour incorporer certains calculs d'adresse directement dans le cache lui-même. Pour rappel, certains modes d'adressage impliquent un calcul d'adresse, qui ajoute une constante à une adresse de base. Généralement, l'adresse de base est l'adresse d'un tableau ou d'une structure, et la constante ajoutée indique la position de la donnée dans le tableau/la structure. Les caches hashés et les caches adressés par somme permettent de faire l'addition directement dans la mémoire cache. Voyons d'abord les caches hashés, avant de passer aux caches adressés par somme. Sur les '''caches hashés''', l'addition est remplacée par une autre opération, par exemple des opérations bit à bit du style XOR, AND ou OR, etc. Seulement, utiliser des opérations bit à bit pose un problème : il arrive que deux couples Adresse/décalage donnent le même résultat. Par exemple, le couple Adresse/décalage 11101111/0001 donnera la même adresse que le couple 11110000/0000. Dit autrement, deux adresses censées être différentes (après application du décalage) sont en réalité attribuées à la même ligne de cache. Il est toutefois possible de gérer ces situations, mais cela demande des astuces de haute volée pour faire fonctionner la mémoire cache correctement. Sur les '''caches adressés par somme''', le décodeur est modifié pour se passer de l'addition. Pour comprendre comment, il faut rappeler qu'un décodeur normal est composé de comparateurs, qui vérifient si l'entrée est égale à une constante bien précise. Sur un cache ordinaire, l'addition est faite séparément du décodage des adresses par le cache, dans l'unité de calcul ou dans l'unité de génération d'adresse. [[File:Non sum adressed cache.png|centre|vignette|upright=2|Cache normal.]] Mais les caches adressés par somme modifient le décodeur, qui est alors composé de comparateurs qui testent si la somme adresse + décalage est égale à une constante. [[File:Cache adressé par somme.png|centre|vignette|upright=2|Cache adressé par somme.]] Chaque circuit du décodeur fait le test suivant, avec K une constante qui dépend du circuit : : <math>A + B = K</math> Ce qui est équivalent à faire le test suivant : : <math>A + B - K = 0</math> En complément à deux, on a <math>- K = \overline{K} + 1</math>. En injectant dans l'équation précédente, on a : : <math>A + B + \overline{K} + 1 = 0</math> En réorganisant les termes, on a : : <math>A + B + \overline{K} = - 1</math> Il suffit d'utiliser un additionneur ''carry-save'' pour faire l'addition des trois termes. Rappelons qu'un tel additionneur fournit deux résultats en sortie : une somme calculée sans propager les retenues et les retenues en question. Notons que les retenues sont à décaler d'un cran, vu qu'elles sont censées s'appliquer à la colonne suivante. En notant la somme S et les retenues R, on a: : <math>S + (R << 1) = - 1 </math>, le décalage d'un cran à gauche étant noté <math><< 1</math>. Ensuite, -1 est codé avec un nombre dont tous les bits sont à 1 en complément à un/deux. : <math>S + (R << 1) = 111 \cdots 111111</math> [[File:Sum + retenue add.png|centre|vignette|upright=2|Sum + retenue add]] Un simple raisonnement nous permet de savoir si le résultat est bien -1, sans faire l'addition <math>S + (R << 1)</math>. En effet, on ne peut obtenir -1 que si la somme est l'inverse des retenues : un 0 dans le premier nombre correspond à un 1 dans l'autre, et réciproquement. En clair, on doit avoir <math>\overline{S} = R << 1</math>. Pour vérifier cela, il suffit de faire un simple XOR entre la somme et les retenues décalées d'un cran. On a alors : : <math>S \oplus (R << 1) = 111 \cdots 111111</math> La comparaison avec -1 se fait avec une porte ET à plusieurs entrées. En effet, la porte donnera un 1 seulement si tous les bits d'entrée sont à 1, ce qui est ce qu'on veut tester. Au final, l'additionneur pour l'addition adresse + décalage est remplacé par un additionneur carry-save suivi d'une couche de portes XOR et d'un comparateur avec une constante, ce qui économise de circuits et améliore les performances. [[File:Final circuit of sum addressed cache.png|centre|vignette|upright=2|Cache adressé par somme.]] En prenant en compte que la constante K est justement une constante, certaines entrées de l'additionneur carry-save sont toujours à 0 ou à 1, ce qui permet quelques simplifications à grand coup d’algèbre de Boole. Chaque additionneur complet qui compose l’additionneur carry-save est remplacée par des demi-additionneurs (ou par un circuit similaire). Autant dire que l'on gagne tout de même un petit peu en rapidité, en supprimant une couche de portes logiques. Le circuit de décodage économise aussi des portes logiques, ce qui est appréciable. ==Les caches à accès uniforme et non-uniforme== Intuitivement, le temps d'accès au cache est le même pour toutes les lignes de cache. Il s'agit de cache appelés '''caches à accès uniforme''', sous-entendu à temps d'accès uniforme. Mais sur les caches de grande capacité, il arrive souvent que le temps de propagation des signaux varie fortement suivant la ligne de cache à lire. D'ordinaire, on se cale sur la ligne de cache la plus lente pour caler la fréquence d'horloge du cache, même si on pourrait faire mieux. Cependant, les '''caches à accès non uniforme''' ont une latence différente pour chaque ligne d'un même cache. Certaines lignes de cache sont plus rapides que d'autres. Niveau terminologie, nous allons parler de caches UCA et NUCA : ''Uniform Access Cache'' pour les caches à accès uniforme, ''Non-Uniform Access Cache'' pour les caches à accès non-uniforme. [[File:Caches UCA et NUCA.png|vignette|Caches UCA et NUCA.]] Les caches NUCA et UCA sont souvent composés de plusieurs banques séparées, typiquement une par voie. Sur les caches UCA, les banques sont interconnectées avec le processeur de manière à ce que toutes les interconnexions ont la même longueur pour toutes les banques. Typiquement, les banques sont organisées en carré, avec les interconnexions qui partent du centre, avec une disposition en H, illustrée ci-contre Mais avec les caches NUCA, ce n'est pas le cas. Les interconnexions sont simplifiées et ont des longueurs différentes. Les caches NUCA n'ont pas tous le même genre d'interconnexions, qui dépendent du cache NUCA. En général, les interconnexion forme un réseau avec des sortes de routeurs qui redirigent les données/commandes vers la bonne destination : cache ou processeur. Les banques plus proches du processeur sont accessibles plus rapidement que celles éloignées, même si la différence n'est pas énorme. Les caches NUCA sont généralement associatifs par voie. Les plus simples utilisent une banque par voie pour le cache, ce qui fait que certaines voies répondent plus vite que les autres. La détection des succès de cache est alors plus rapide si la donnée lue/écrite est dans une voie/banque rapide. En théorie, les défauts de cache demandent de vérifier toutes les banques, et se calent donc sur la pire latence. Mais divers caches se débrouillent pour que ce ne soit pas le cas, soit en vérifiant les banquyes unes par une, soit par un mécanisme de recherche plus complexe. Les caches NUCA sont surtout utilisés pour les caches L3 et L4, éventuellement les caches L2. Les caches L1 sont systématiquement des caches UCA, car la latence de l'accès au cache L1 est utilisée par le processeur pour décider quand lancer les instructions. Pour simplifier, le processeur peut démarrer en avance une instruction avant qu'une opérande soit lue dans le cache L1, de manière à ce que la donnée arrive en entrée de l'ALU pile en même temps que l'instruction. Une histoire d'exécution dans le désordre et d'émission anticipée des instructions qu'on détaillera dans une bonne dizaine de chapitres. Toujours est-il que tout est plus simple pour le processeur si le cache L1 a un temps d'accès fixe. Par contre, les caches L3 et L4 sont traités en attendant que les données arrivent, le processeur reprend l'exécution des instructions quand les caches L3 et L4 ont terminé de répondre, pas avant. Avec l'association une banque = une voie, la correspondance ligne de cache → bloc de mémoire qui est statique : on ne peut pas déplacer le contenu d'une ligne de cache dans une autre portion de mémoire plus rapide suivant les besoins. Mais la recherche académique a étudié le cas où la correspondance entre une ligne de cache et une banque varie à l’exécution. Pour nommer cette distinction, on parle de caches S-NUCA (''Static NUCA'') et D-NUCA (''Dynamic NUCA''). Intuitivement, on s'attend à ce que les caches D-NUCA soient plus performants que les caches S-NUCA. Les lignes de cache les plus utilisées peuvent migrer dans une banque rapide, alors que les lignes de cache moins utilisées vont dans une banque éloignée. Les lignes de cache se répartissent dans le cache dynamiquement dans les banques où elles sont le plus adaptées. Mais paradoxalement, le gain des caches D-NUCA est presque nul, voire insignifiant. La raison est que les caches D-NUCA doivent incorporer un système pour déterminer dans quelle banque se situe la donnée pour détecter les succès/défauts de cache, ainsi qu'un système pour migrer les données entre banques. Et ce système augmente le temps d'accès au cache, réduisant à néant l'intérêt d'un cache D-NUCA. Si on économise quelques microsecondes de temps d'accès en passant d'un cache UCA à un cache S-NUCA, ce n'est pas pour les perdre en passant à un D-NUCA. La majorité des caches D-NUCA sont donc en cours de recherche, mais ne sont pas utilisés en pratique. ==La tolérance aux erreurs des caches== Une mémoire cache reste avant tout une mémoire RAM, bien que ce soit de la SRAM. Elle n'est pas parfaite et est donc sujette à des erreurs, qui peuvent inverser un bit ou l'effacer. De telles erreurs sont liées à des rayons cosmiques très énergétiques, à des particules alpha produites par le packaging ou le métal deu circuit intégré, peu importe : l'essentiel est qu'ils inversent parfois un bit. Les mémoires modernes savent se protéger contre de telles erreurs, en utilisant trois moyens. ===Les mémoires caches ECC et à bit de parité=== Le premier moyen est l'usage de codes correcteurs d'erreurs, qui ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Les bits ajoutés dépendent de la donnée mémorisée dans le byte, et servent à détecter une erreur, éventuellement à la corriger. Le cas le plus simple ajoute un simple bit de parité pour chaque byte et se contente de détecter les erreurs dans les corriger. Les autres codes ECC permettent eux de corriger des erreurs, mais ils demandent d'ajouter au moins deux bits par byte, ce qui a un cout en circuit plus élevé. Un simple bit de parité permet de détecter qu'un bit a été inversé, mais ne permet pas de corriger l'erreur. En soi, ce n'est pas un problème. Si une erreur est détectée, on considère que la ligne de cache est invalide. Le cache gère la situation comme un défaut de cache et va chercher la donnée valide en mémoire RAM. Le cout en circuits est donc faible, mais les défauts de cache sont plus nombreux. Les codes ECC sont eux capables de corriger les erreurs, si elles ne modifient pas trop de bits d'un coup. Par contre, ils utilisent deux à trois bits par octet, ce qui a un cout en circuits loin d'être négligeable. Il y a donc un compromis entre défauts de cache et cout en circuits. La gestion de l'ECC est différente suivant le niveau de cache. Généralement, le cache L1 n'utilise pas l'ECC mais se contente d'un simple bit de parité pour éviter la corruption de ses données. Le cache étant petit, les corruptions de données sont assez rares, et les défauts de cache induits faibles. Il est plus important d'utiliser un code de détection d'erreur simple, rapide, qui ne ralentit pas le cache et n'augmente pas sa latence. Si une ligne de cache est corrompue, il a juste à aller lire la ligne depuis le cache L2, ou un niveau de cache inférieur. Du moins, c'est possible sur le cache en question est un cache inclusif et/ou ''write-through''. Par contre, le niveau de cache L2 et ceux en-dessous utilisent presque systématiquement une mémoire SRAM ECC. La raison principale étant que ce sont des caches assez gros, pour lesquels la probabilité d'une erreur est assez élevée. Plus une mémoire a de bits et prend de la place, plus il y a une chance élevée qu'un bit s'inverse. Et vu que les caches L2/L3/L4 sont par nature plus lents et plus gros, ils peuvent se permettre le cout en performance lié à l'ECC, idem pour le cout en circuit. Sans compter qu'en cas d'erreur, ils doivent aller lire la ligne de cache originelle en mémoire RAM, ce qui est très lent ! Mieux vaut corriger l'erreur sur place en utilisant l'ECC. ===L'usage du ''memory scrubbing'' sur les caches=== La plupart des erreurs ne changent qu'un seul bit dans un byte, mais le problème est que ces erreurs s'accumulent. Entre deux accès à une ligne de cache, il se peut que plusieurs erreurs se soient accumulées, ce qui dépasse les capacités de correction de l'ECC. Dans ce cas, il existe une solution appelée le ''memory scrubbing'', qui permet de résoudre le problème au prix d'un certain cout en performance. Pour rappel, l'idée est de vérifier les lignes de caches régulièrement, pour éviter que les erreurs s'accumulent. Par exemple, on peut vérifier chaque ligne de cache toutes les N millisecondes, et corriger une éventuelle erreur lors de cette vérification. En faisant des vérifications régulières, on garantir que les erreurs n'ont pas le temps de s'accumuler, sauf en cas de malchance avec des erreurs très proches dans le temps. Il ne s'agit pas d'un rafraichissement mémoire, car les SRAM ne s'effacent pas), mais ça a un effet similaire. Et évidemment, le ''memory scrubbing'' a un cout en performance. On peut faire une comparaison avec le rafraichissement mémoire : les rafraichissement réguliers réduisent les performances, car cela fait des accès en plus. Des accès qui sont de plus timés à des instants bien précis qui ne sont pas forcément les plus adéquats. Il est possible qu'un rafraichissement ait lieu en même temps qu'un accès mémoire et le rafraichissement a la priorité, ce qui réduit les performances. La même chose arrive avec les vérifications du ''memory scrubbing''. Malgré tout, la technique a été utilisée sur les caches de certains processeurs commerciaux, dont des processeurs AMD Athlon et Athlon 64. Elle est surtout utilisable sur les caches L2/L3, pour lesquels le cout du pseudo-rafraichissement est acceptable. ==Un exemple de cache : le cache d'instruction== La grande majorité des processeurs utilise deux caches L1 séparés : un '''cache d'instructions''' dédié aux instructions, et un autre pour les données. Une telle organisation permet de charger une instruction tout en lisant une donnée en même temps. Notons que seul le cache L1 est ainsi séparé entre cache de données et d'instructions. Le cache d’instruction se situe en théorie entre l'unité de chargement et l'unité de décodage. En effet, ce cache prend en entrée une adresse et fournit une instruction. L'adresse est fournie par le ''program counter'', l'instruction est envoyée dans l'unité de décodage. Le cache se situe donc entre les deux. Le cache de données L1 est connecté au chemin de données, et notamment aux unités de communication avec la mémoire, pas au séquenceur. [[File:Caches L1 et positions dans le processeur.png|centre|vignette|upright=2.5|Caches L1 et positions dans le processeur]] Les deux caches sont reliés au processeur par des bus séparés, l'ensemble ressemble à une architecture Harvard, mais où les caches remplacent les mémoires RAM/ROM. Le cache d'instruction prend la place de la mémoire ROM et le cache de données prend la place de la mémoire RAM. Évidemment, il y a des niveaux de caches en dessous des caches de données/instruction, et ceux-ci contiennent à la fois données et instructions, les deux ne sont pas séparées dans des mémoires/caches séparés. Raison pour laquelle l'ensemble est appelé une '''architecture Harvard modifiée'''. Architecture Harvard, car l'accès aux données et instructions se font par des voies séparées pour le processeur, modifiée car la séparation n'est effective que pour le cache L1 et pas les autres niveaux de cache, et encore moins la RAM. Sur les processeurs modernes, il arrive très souvent que le processeur doive charger une instruction et lire/écrire une donnée en même temps. Et à vrai dire, c'est la règle plus que l'exception. L'usage d'une architecture Harvard modifiée permet cela très facilement : on peut accéder au cache d'instruction via un bus, et au cache de donnée avec l'autre ===Pourquoi scinder le cache L1 en cache d'instruction et de données=== L'usage d'un cache d’instruction séparé du cache de données est à contraster avec l'usage d'un cache L1 multiport unique, capable de mémoriser à la fois instructions et données. Les deux solutions sont possibles ont été utilisées. Les premiers processeurs avaient un cache L1 unique et multiport, mais ce n'est plus le cas sur les processeurs modernes, car les contraintes ne sont pas les mêmes. Le compromis à faire est celui entre deux petits caches rapides et un gros cache plus lent. Pour rappel, plus un cache est petit, plus il est rapide et chauffe moins. Donc au lieu d'utiliser, par exemple, un gros cache lent de 64 Kibioctets, on utilise deux caches de 32 kibioctets, plus rapides. La capacité totale est la même, mais le temps d'accès plus faible. En termes de temps d'accès, la meilleure solution est celle des deux caches simple port. Mais pour ce qui est de l'économie de circuits, c'est moins évident. Entre deux mémoires simple port et une mémoire multiport, la différence en termes de transistors est ambigüe et dépend de la capacité des caches. La différence est surtout notable pour les gros caches, moins pour les petits caches. Il faut aussi tenir compte de la capacité effective. Avec deux caches séparés, la répartition de la capacité du cache L1 est fixée une bonne fois pour toutes. Par exemple, avec un cache d'instruction de 32 KB et un cache de données de 32 KB, impossible d'allouer 40 KB aux données et 20 aux instructions. Alors qu'avec un cache L1 unique de 64 KB, on pourrait le faire sans soucis. La répartition se fait naturellement, en fonction de la politique de remplacement du cache et est proche de l'optimal. C'est là un désavantage des caches d'instructions/données séparés : une capacité effective moindre. Tout cela explique pourquoi le cache L1 est le seul à être ainsi scindé en deux, avec une séparation entre instructions et données : les contraintes au niveau du cache L1 et L2 ne sont pas les mêmes. Pour les caches L1, le temps d'accès est plus important que la capacité, ce qui favorise les caches séparés. Par contre, pour les caches L2/L3/L4, le temps d'accès n'est pas déterminant, alors que la capacité effective et l'économie en circuits sont significatives. ===La connexion des caches L1 avec le cache L2=== Pour les connexions avec le cache L2, tout dépend du processeur. Certains utilisent un cache L2 multiport, qui permet aux deux caches L1 de lire ou écrire dans le cache L2 simultanément. [[File:Cache d'instructions.png|centre|vignette|upright=1.5|Cache d'instructions.]] Si le cache L2 ne gère pas les accès simultanés, il n'y a qu'un seul bus relié aux caches L1 et au cache L2. On doit effectuer un arbitrage pour décider quel cache a la priorité, chose qui est réalisé par un circuit d'arbitrage spécialisé. [[File:Circuit d'arbitrage du cache.png|centre|vignette|upright=1.5|Circuit d'arbitrage du cache.]] Généralement, les caches d'instructions peuvent se permettre d'être plus petits que les caches de données, car les programmes sont souvent plus petits que les données manipulées. Songez que des programmes de quelques mébioctets peuvent parfois remplir la RAM avec plusieurs gibioctets de données. Lancez votre navigateur internet et ouvrez une page web un peu chargée, pour vous en convaincre ! ===Les spécificités du cache d'instruction : lecture seule, bloquant, etc=== Les instructions sont rarement modifiées ou accédées en écritures, contrairement aux données. Et cela permet d'utiliser un cache simplifié pour les instructions. Autant un cache généraliste doit permettre les lectures et écritures depuis le processeur (avec les échanges avec la RAM), autant un cache d'instruction peut se contenter des lectures provenant du CPU et des échanges avec la RAM. Le cache d'instructions est donc très souvent en « lecture seule » : le processeur ne peut pas écrire dedans, mais juste le lire ou charger des instructions dedans. Un cache d'instruction est donc plus simple qu'un cache pour les données : on peut retirer les circuits en charge de l'écriture (mais on doit laisser un port d'écriture pour charger les instructions dedans). Le gain en circuits permet d'utiliser un cache d'instruction plus gros ou au contraire de laisser de la place pour le cache de données. Le gain en termes de capacité compense alors un peu les inconvénients des caches séparés. Par contre, cela complique la gestion du code automodifiant, c'est-à-dire des programmes dont certaines instructions vont aller en modifier d'autres, ce qui sert pour faire de l'optimisation ou est utilisé pour compresser ou cacher un programme (les virus informatiques utilisent beaucoup de genre de procédés). Quand le processeur exécute ce genre de code, il ne peut pas écrire dans ce cache L1 d'instructions, mais doit écrire dans le cache L2 ou en RAM, avant de recharger les instructions modifiées dans le cache L1. Cela qui prend du temps et peut parfois donner lieu à des erreurs si le cache L1 n'est pas mis à jour. Les algorithmes de remplacement des lignes de cache optimaux pour les données ne le sont pas pour les instructions, de même que la taille optimale du cache, la taille des lignes de cache optimale, ou même les algorithmes de préchargement. Par exemple, pour le remplacement des lignes de cache, un simple algorithme LRU est presque optimal pour les instructions, autant il peut donner de mauvaises performances quand on manipule beaucoup de tableaux. Cela justifie d'utiliser des caches spécialisés pour chacune. On peut adapter le cache d'instruction à son contenu, ce qui le rend plus rapide ou plus petit à performance égale. Les caches d'instructions sont généralement des caches bloquants. Il ne servirait à rien de rendre un cache d'instruction non-bloquant, le cout en circuits ne se traduirait pas par une augmentation significative des performances. À l'opposé, les caches de données sont non-bloquants sur les architectures modernes, pour des raisons de performance. Ce qui rend la séparation assez intéressante, les deux caches ayant des besoins différents et des implémentations différentes, cela permet d'optimiser le cout en transistors des caches. ===L'impact du cache d'instruction sur les performances=== Sur les architectures conventionnelles, le cache d'instruction a plus d'impact sur les performances que le cache de données. La raison principale est que les instructions ont une meilleure localité spatiale et temporelle que pour les données. Pour la localité spatiale, les instructions consécutives se suivent en mémoire, alors que rien ne garantit que des données utilisées ensemble soient regroupées en mémoire. Pour localité temporelle, elle est très variable pour les données, mais très courante pour les instructions du fait de l'usage fréquent des boucles et des fonctions. : La présence de branchements atténue la localité temporelle des instruction, sauf que la majorité des branchements sautent à un endroit très proche, seuls les appels de fonction brisent la localité spatiale. La conséquence est qu'il arrive que certains CPU aient un cache L1 d'instruction plus gros que celui pour les données. On parle alors de '''cache L1 asymétriques'''. Un exemple est celui des processeurs AMD de microarchitecture Zen, dont le cache d'instruction était deux fois plus gros que le cache de données. Leur cache d'instruction faisait 64 kibioctets, contre seulement 32 pour le cache de données. D'ailleurs, il existe des processeurs assez extrêmes qui se contentent d'un cache d'instruction unique, sans cache de données. C'est le cas sur les processeurs vectoriels ou les GPU que nous verrons dans les chapitres de fin de ce wikilivres. De tels processeurs sont spécialisés dans la manipulation de tableaux de données, traitement qui a une faible localité temporelle. En conséquence, utiliser un cache de données n'est pas vraiment utile, voire peu être contreproductif, alors qu'un cache d’instruction fonctionne parfaitement. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Compléments sur les mémoires de masse | prevText=Compléments sur les mémoires de masse | next=Le préchargement | nextText=Le préchargement }} </noinclude> ovc5s9i7c9gwu7yyn64g95hri3ybidb 765120 765119 2026-04-26T15:28:25Z Mewtow 31375 /* L'exemple des processeurs 386 et du contrôleur de cache 82385 */ 765120 wikitext text/x-wiki Le cache est une mémoire intercalée entre la mémoire et un processeur, plus rarement à l'intérieur d'un périphérique. Il est souvent fabriquée avec de la mémoire SRAM, parfois avec de l'eDRAM. Sans lui, on se croirait à l'âge de pierre tellement nos PC seraient lents ! En effet, la mémoire est très lente comparée au processeur. Le temps mis pour accéder à la mémoire est du temps durant lequel le processeur n'exécute pas d'instruction (sauf cas particuliers impliquant un pipeline). Pour diminuer ce temps d'attente, il a été décidé d'intercaler une mémoire petite mais rapide, entre le processeur et la mémoire. Ainsi, le processeur accède à un cache très rapide plutôt qu'à une RAM beaucoup plus lente. ==L'accès au cache== Le cache contient une copie de certaines données présentes en RAM. La copie présente dans le cache est accessible bien plus rapidement que celle en RAM, vu que le cache est plus rapide. Mais seule une petite partie de ces données sont copiées dans le cache, les autres données devant être lues ou écrites dans la RAM. Toujours est-il que le cache contient une copie des dernières données accédées par le processeur. Une donnée est copiée dans la mémoire cache quand elle est lue ou écrite par le processeur. Le processeur conserve une copie de la donnée dans le cache après son premier accès. Les lectures/écritures suivantes se feront alors directement dans le cache. Évidemment, au fur et à mesure des accès, certaines données anciennes sont éliminées du cache pour faire de la place aux nouveaux entrants, comme nous le verrons plus tard. [[File:Principe d'une mémoire cache.gif|centre|vignette|upright=2|Principe d'une mémoire cache.]] La mémoire cache est invisible pour le programmeur, qui ne peut pas déceler celles-ci dans l'assembleur. Les accès mémoire se font de la même manière avec ou sans le cache. La raison à cela est que le cache intercepte les accès mémoire et y répond s'il en a la capacité. Par exemple, si le cache intercepte une lecture à une adresse et que le contenu de cette adresse est dans le cache, le cache va outrepasser la mémoire RAM et la donnée sera envoyée par le cache au lieu d'être lue en RAM. par contre, si un accès se fait à une adresse pour laquelle le cache n'a pas la donnée, alors l'accès mémoire sera effectué par la RAM de la même manière que si le cache n'était pas là. [[File:Accès au cache.png|centre|vignette|upright=2|Accès au cache]] ===Les succès et défauts de caches=== Tout accès mémoire est intercepté par le cache, qui vérifie si la donnée demandée est présente ou non dans le cache. Si la donnée voulue est présente dans le cache, on a un '''succès de cache''' (''cache hit'') et on accède à la donnée depuis le cache. Sinon, c'est un '''défaut de cache''' (''cache miss'') et on est obligé d’accéder à la RAM. Les défauts de cache peuvent avoir plusieurs origines. Tout ce qu'il faut savoir est que lorsque le processeur accède à une donnée ou une instruction pour la première fois, il la place dans la mémoire cache car elle a de bonnes chances d'être réutilisée prochainement. La raison à cela est qu'un programme a tendance à réutiliser les instructions et données qui ont été accédées dans le passé : c'est le ''principe de localité temporelle''. Bien évidement, cela dépend du programme, de la façon dont celui-ci est programmé et accède à ses données et du traitement qu'il fait, mais c'est souvent vrai en général. La première cause des défauts de cache est liée à la taille du cache. À force de charger des données/instructions dans le cache, le cache fini par être trop petit pour conserver les anciennes données. Le cache doit bien finir par faire de la place en supprimant les anciennes données, qui ont peu de chances d'être réutilisées. Ces anciennes données éliminées du cache peuvent cependant être accédées plus tard. Tout prochain accès à cette donnée mènera à un cache miss. C'est ce qu'on appelle un ''Capacity Cache Miss'', ou encore '''défaut de capacité'''. Les seules solutions pour éviter cela consistent à augmenter la taille du cache ou à optimiser le programme exécuté (voir plus bas). Une autre raison pour un défaut est donc la suivante. Lorsqu'on exécute à une instruction ou qu'on accède à donnée pour la première fois, celle-ci n'a pas encore été chargée dans le cache. Le défaut de cache est inévitable : ce genre de cache miss s'appelle un ''Cold Miss'', ou encore un '''défaut à froid'''. De tels défauts sont presque impossibles à éliminer, sauf à utiliser des techniques de préchargement qui chargent à l'avance des données potentiellement utiles. Ces méthodes de préchargement se basent sur le principe de localité spatiale, à savoir le fait que les programmes ont tendance à accéder à des données proches en mémoire. Pour donner un exemple, les instructions d'un programme sont placées en mémoire dans l’ordre dans lequel on les exécute : la prochaine instruction à exécuter est souvent placée juste après l'instruction en cours (sauf avec les branchements). Quand on accède à une donnée ou une instruction, le cache peut précharger les données adjacentes pour en profiter. Nous parlerons de ces techniques de préchargement dans un chapitre dédié, vers la fin du cours. ===Le fonctionnement du cache, vu du processeur=== Vu du processeur, le cache prend en entrée toutes les informations nécessaires pour effectuer un accès mémoire : des signaux de commande, une adresse et la donnée à écrire si besoin. Tout cela est passé en entrée du cache, celui-ci répondant aux accès mémoire via divers bits de contrôles, que le processeur peut lire à souhait. Le cache fournit aussi la donnée à lire, pour les lectures, sur une sortie, connectée directement au bus mémoire/processeur. Globalement, le cache a une capacité limitée, mais il prend en entrée des adresses complètes. Par exemple, sur un processeur 64 bits, le cache prend en entrée des adresses de 64 bits (sauf si optimisations), même si le cache en question ne fait que quelques mébioctets. Les caches sont souvent des mémoires multiports, surtout sur les processeurs récents. Les caches simple port sont rares, mêmes s'ils existent et ont existé par le passé. les caches double port sont eux plus fréquents, et ont généralement un port d'écriture séparé du port de lecture. Mais les caches récents ont plusieurs ports de lecture/écriture et sont capables de gérer plusieurs accès mémoire simultanés. Les données présentes dans le cache sont (pré)chargées depuis la mémoire, ce qui fait que toute donnée dans le cache est la copie d'une donnée en mémoire RAM. Le cache doit faire la correspondance entre une donnée du cache et l'adresse mémoire correspondante. Du point de vue du fonctionnement, on peut voir le cache comme une sorte de table de correspondance, qui mémorise des données, chacune étant associée à son adresse mémoire. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Cela vaut du point de vue du processeur, le fonctionnement interne du cache étant quelque peu différent selon le cache. Il existe des caches dont le fonctionnement interne est bien celui d'une table de correspondance matérielle, d'autres qui sont beaucoup plus optimisés. [[File:Fonctionnement d'une mémoire associative à correspondance.png|centre|vignette|upright=2|Fonctionnement simplifié d'une mémoire cache : les adresses sont dans la colonne de gauche, les données sont dans la colonne de droite. On voit qu'on envoie l'adresse au cache, que celui-ci répond en renvoyant la donnée associée.]] ==La performance des mémoires caches== L'analyse de la performance des mémoires caches est plus riche pour celle des autres mémoires. Sa performance dépend de beaucoup de paramètres, mais on peut cependant citer les principaux. Les deux premiers sont tout bonnement sa latence et son débit, comme pour n'importe quelle autre mémoire. La latence est plus importante que son débit, car le processeur est généralement plus rapide que le cache et qu'il n'aime pas attendre. Mais le critère le plus important pour un cache est sa capacité à empêcher des accès mémoire, son efficacité. Plus les accès mémoire sont servis par le cache au lieu de la RAM, meilleures seront les performances. Pour résumer, la performance d'un cache est surtout caractérisée par deux métriques : le taux de défaut, qui correspond à l’efficacité du cache, et la latence du cache. ===Le taux de succès/défaut=== Le '''taux de succès''' (hit ratio) est un premier indicateur des performances du cache, mais un indicateur assez imparfait. C'est le pourcentage d'accès mémoire qui ne déclenchent pas de défaut de cache. Plus il est élevé, plus le processeur accède au cache à la place de la RAM et plus le cache est efficace. Certains chercheurs préfèrent utiliser le '''taux de défauts''', à savoir le pourcentage d'accès mémoire qui entraînent un défaut de cache. Plus il est bas, meilleures sont les performances. Le taux de défaut est relié au taux de succès par l'équation <math>T_\text{succes} = 1 - T_\text{defaut}</math>. Par définition, il est égal à : : <math>\text{Taux de défauts de cache} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d’accès mémoires}}</math> Plutôt que de comparer le nombre de défauts/succès de cache au nombre d'accès mémoire, il est aussi possible de diviser le nombre de défauts par le nombre total d'instructions. On obtient alors le '''taux de défauts/succès par instruction''', une autre métrique utile. Par définition, elle est égale à : : <math>\text{Taux de défauts par instruction} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d'instructions}} = \text{Taux de défauts de cache} \times \frac{\text{Nombre d’accès mémoires}}{\text{Nombre d'instructions}}</math> Si certains défauts de cache sont inévitables quel que soit le cache, comme les défauts à froids, mentionnés plus haut, d'autres défauts peuvent être évités en augmentant la capacité du cache. C'est le cas des défauts de capacité qui sont causés par un accès à une donnée qui a été éliminée du cache faute de place. Plus le cache est gros, moins il a de chances d'être rempli, moins il doit rapatrier de données, plus son taux de succès augmente. Mais nous reviendrons sur le lien entre taille du cache et taux de défaut plus bas. Le taux de succès ne dépend pas que du cache, mais aussi de la conception des programmes exécutés. Une bonne utilisation du cache (ainsi que de la mémoire virtuelle) repose sur le programmeur qui doit prendre en compte les principes de localités dès la conception de ses programmes. Par exemple, un programmeur peut parfaitement tenir compte du cache au niveau de son algorithme : on peut citer l'existence des algorithmes ''cache oblivious'', qui sont conçus pour être optimaux quelle que soit la taille du cache. Le programmeur peut aussi choisir ses structures de données de manière à améliorer la localité. Par exemple, un tableau est une structure de donnée respectant le principe de localité spatiale, tandis qu'une liste chaînée ou un arbre n'en sont pas (bien qu'on puisse les implémenter de façon à limiter la casse). D'autres optimisations sont parfois possibles : par exemple, le sens de parcours d'un tableau multidimensionnel peut faire une grosse différence. Cela permet des gains très intéressants pouvant se mesurer avec des nombres à deux ou trois chiffres. Je vous recommande, si vous êtes programmeur, de vous renseigner le plus possible sur les optimisations de code ou algorithmiques qui concernent le cache : il vous suffira de chercher sur Google. Il y a une citation qui résume bien cela, prononcée par un certain Terje Mathisen. Si vous ne le connaissez pas, cet homme est un vieux programmeur (du temps durant lequel on codait encore en assembleur), grand gourou de l’optimisation, qui a notamment travaillé sur le moteur de Quake 3 Arena. {{BlocCitation|Almost all programming can be viewed as an exercise in caching.|auteur=Terje Mathisen}} ===La latence moyenne d'un cache=== Le temps mis pour lire ou écrire une donnée varie en présence d'un cache. Certaines lectures/écritures vont atterrir directement dans le cache (succès) tandis que d'autres devront aller chercher leur contenu en mémoire RAM (défaut de cache). Dans tous les cas, qu'il y ait défaut ou non, le cache sera consulté et mettra un certain temps à répondre, égal au temps de latence du cache. Tous les accès mémoires auront donc une durée au moins égale au temps de latence du cache, qui sera notée <math>T_c</math>. En cas de succès, le cache aura effectué la lecture ou l'écriture, et aucune action supplémentaire n'est requise. Ce qui n'est pas le cas en cas de défaut : le processeur devra aller lire/écrire la donnée en RAM, ce qui prend un temps supplémentaire égal au temps de latence de la mémoire RAM. Un défaut ajoute donc un temps, une pénalité, à l'accès mémoire. Dans ce qui suivra, le temps d'accès à la RAM sera noté <math>T_m</math>. Fort de ces informations, nous pouvons calculer le temps de latence moyen d'un accès mémoire, qui est la somme du temps d'accès au cache (pour tous les accès mémoire), multiplié par le temps lié aux défauts. On a alors : : <math>T = T_c + \text{Taux de défaut} \times T_m</math> On voit que plus le taux de succès est élevé, plus le temps de latence moyen sera bas, et inversement. Ce qui explique l'influence du taux de succès sur les performances du cache, influence assez importante sur les processeurs actuels. De nos jours, le temps que passe le processeur dans les défauts de cache devient de plus en plus un problème au fil du temps, et gérer correctement le cache est une nécessité, particulièrement sur les processeurs multi-cœurs. Il faut dire que la différence de vitesse entre processeur et mémoire est tellement importante que les défauts de cache sont très lents : alors qu'un succès de cache va prendre entre 1 et 5 cycles d'horloge, un cache miss fera plus dans les 400-1000 cycles d'horloge. Tout ce temps sera du temps de perdu que le processeur aura du mal à mitiger. Autant dire que réduire les défauts de cache est beaucoup plus efficace que d'optimiser les calculs effectués par le processeur (erreur courante chez de nombreux programmeurs, notamment débutants). ===L'impact de la taille du cache sur le taux de défaut et la latence=== Il y a un lien entre taille du cache, taux de défaut, débit binaire et latence moyenne. Globalement, plus un cache est gros, plus il est lent. Simple application de la notion de hiérarchie mémoire vue il y a quelques chapitres. Les raisons à cela sont nombreuses, mais nous ne pouvons pas les aborder ici, car il faudrait que nous sachions comment fonctionne un cache et ce qu'il y a à l'intérieur, ce qui sera vu dans la suite du chapitre. Toujours est-il que la latence moyenne d'un cache assez gros est assez importante. De même, le débit binaire d'un cache diminue avec sa taille, mais dans une moindre mesure. Les petits caches ont donc un gros débit binaire et une faible latence, alors que c'est l'inverse pour les gros caches. Une grande capacité de cache améliore le taux de succès, mais cela se fait au détriment de son temps de latence et de son débit, ce qui fait qu'il y a un compromis assez difficile à trouver entre taille du cache, latence et débit. Il peut arriver qu'augmenter la taille du cache augmente son temps d'accès au point d’entraîner une baisse de performance. Par exemple, les processeurs Nehalem d'Intel ont vus leurs performances dans certains jeux vidéos baisser de 2 à 3 %, malgré de nombreuses améliorations architecturales, parce que la latence du cache L1 avait augmentée de 2 cycles d'horloge. Pour avoir une petite idée du compromis à faire, regardons la relation entre taille du cache et taux de défaut. Il existe une relation approximative entre ces deux variables, appelée la '''loi de puissance des défauts de cache'''. Elle donne le nombre total de défaut de cache en fonction de la taille du cache et de deux autres paramètres. Voici cette loi : : <math>\text{Taux de défauts de cache} \approx K \times \text{Taille du cache}^{- \alpha }</math>, avec <math>K</math> et <math>\alpha</math> deux coefficients qui dépendent du programme exécuté. Le coefficient <math>\alpha</math> est généralement compris entre 0.3 et 0.7, guère plus, et varie suivant le programme exécuté. Précisons que cette loi ne marche que si le cache est assez petit par rapport aux données à utiliser. Pour un cache assez gros et des données très petites, la relation précédente est mise en défaut. Pour s'en rendre compte, il suffit d'étudier le cas extrême où toutes les données nécessaires tiennent dans le cache. Dans ce cas, il n'y a qu'un nombre fixe de défauts de cache : autant qu'il faut charger de données dans le cache. Le nombre de défauts de cache observé dans cette situation n'est autre que le coefficient <math>K</math> de la situation précédente, mais il n'y a aucune dépendance entre taux de défaut et taille du cache. L'origine de cette relation s'explique quand on regarde combien de fois chaque donnée est réutilisée lors de l’exécution d'un programme. La plupart des données finissent par être ré-accédées à un moment ou un autre et il se passe un certain temps entre deux accès à une même donnée. Sur la plupart des programmes, les observations montrent que beaucoup de réutilisations de données se font après un temps très court et qu'inversement, peu de ré-accès se font après un temps inter-accès long. Si on compte le nombre de réutilisation qui ont un temps inter-accès bien précis, on retrouve une loi de puissance identique à celle vue précédemment : : <math>\text{Nombre de réaccès avec un temps inter-accès égal à t} \approx K \times t^{- \beta}</math>, avec t le temps moyen entre deux réutilisations. Le coefficient <math>\beta</math> est ici compris entre 1.7 et 1.3. De manière générale, les coefficients <math>\alpha</math> et <math>\beta</math> sont reliés par la relation <math>\alpha = 1 - \beta</math>, ce qui montre qu'il y a un lien entre les deux relations. Précisons cependant que la loi de puissance précédente ne vaut pas pour tous les programmes informatiques, mais seulement pour la plupart d’entre eux. Il n'est pas rare de trouver quelques programmes pour lesquels les accès aux données sont relativement prédictibles et où une bonne optimisation du code fait que la loi de puissance précédente n'est pas valide. La loi de puissance des défauts de cache peut se démontrer à partir de la relation précédente, sous certaines hypothèses. Si un suppose que le cache est assez petit par rapport aux données, alors les deux relations sont équivalentes. L'idée qui se cache derrière la démonstration est que si le temps entre deux accès à une donnée est trop long, alors la donnée accédée aura plus de chance d'être rapatriée en RAM, ce qui cause un défaut de cache. La chance de rapatriement dépend de la taille du cache, un cache plus gros peut conserver plus de données et a donc un temps avant rapatriement plus long. ==Les lignes de cache et leurs tags== Du point de vue du processeur, les lectures et écritures se font mot mémoire par mot mémoire. Un processeur avec des entiers de 64 bits recoit des données de 64 bits de la part du cache, et y écrit des mots de 64 bits. Mais quand on regarde comment sont stockées les données à l'intérieur du cache, les choses sont différentes. ===Les lignes de cache=== Les données sont mémorisées dans le cache par blocs de plusieurs bytes, d'environ 64 à 256 octets chacun, qui portent le nom de '''lignes de cache'''. Les lignes de cache sont l'unité de stockage que l'on trouve à l'intérieur du cache, mais elles servent aussi d'unité de transaction avec la mémoire RAM. Sur les caches actuels, on transfère les données entre le cache et la RAM ligne de cache par ligne de cache, dans la limite de la taille du bus mémoire. Mais d'autres caches plus anciens permettaient de faire des transferts plus fins. C’est-à-dire qu'on pouvait mettre à jour quelques octets dans une ligne de cache sans avoir à la recopier intégralement depuis ou dans la mémoire RAM. En théorie, on pourrait imaginer des caches où les données sont stockées différemment, où l'unité serait le mot mémoire, par exemple. Par exemple, sur un processeur 64 bits, on aurait une ligne de cache de 64 bits. Cela aurait l'avantage de la simplicité : les transferts entre le processeur et la mémoire serait de même taille, l'intérieur du cache ressemblerait à son interface montrée au processeur. Mais cela aurait quelques défauts qui sont compensés par l'organisation en lignes de cache de grande taille. Le premier avantage des lignes de cache est lié à la localité spatiale, la tendance qu'on les programmes à accéder à des données proches les unes des autres. Des accès mémoires consécutifs ont tendance à se faire à des adresses proches, qui ont de bonnes chances d'être dans la même ligne de cache. Et des accès consécutifs à une même ligne de cache sont plus rapides que des accès à deux lignes distinctes. Une autre raison est tout simplement que cela simplifie considérablement la circuiterie du cache. Pour une capacité identique, il vaut mieux avoir peu de lignes de cache assez grosses, que beaucoup de petites lignes de cache. La raison est que les circuits du cache, comme le décodeur, l'encodeur et autres, ont moins de sorties et sont donc plus simples. ===L'alignement des lignes de cache=== Les lignes de cache sont des blocs de plusieurs dizaines à centaines de bytes, dont la taille est presque toujours une puissance de deux. De plus, les lignes de cache sont alignées en mémoire. Nous avions déjà abordé la notion d'alignement mémoire dans un chapitre précédent, mais le concept d'alignement des lignes de cache est quelque peu différent. Quand nous avions parlé d'alignement auparavant, il s'agissait de l'alignement des données manipulées par le processeur, qui faisait partie du jeu d'instruction du processeur. Ici, nous parlons d'un alignement totalement différent, invisible pour le programmeur, sans lien avec le jeu d’instruction. Voyons de quoi il retourne. Concrètement, cela veut dire que du point de vue du cache, la RAM est découpée en blocs qui font la même taille qu'une ligne de cache, aux positions prédéterminées, sans recouvrement entre les blocs. Par exemple, pour un cache dont les lignes de cache font 256 octets, le premier bloc est à l'adresse 0, le second est 256 octets plus loin, c'est à dire à l'adresse 256, le troisième à l'adresse 512, la quatrième à l'adresse 768, etc. Une ligne de cache de 256 octets contiendra une donnée provenant d'un bloc de RAM de 256 octets, dont l'adresse est systématiquement un multiple de 256. Il n'est pas possible qu'une ligne de cache contienne un bloc de 256 octets dont l'adresse du premier octet serait l'adresse 64, ou l'adresse 32, par exemple. En clair, les adresses de ces blocs sont des multiples de la taille de la ligne de cache, de la taille des blocs. Cela rappelle les contraintes d'alignement vues dans le chapitre "Le modèle mémoire : alignement et boutisme", mais appliquées aux lignes de cache. L'alignement des lignes de cache a des conséquences pratiques pour la conception des caches. Notons qu'il est en théorie possible d'avoir des caches dont les lignes de cache ne sont pas alignées, mais cela poserait des problèmes majeurs. Il serait en effet possible qu'une donnée soit présente dans deux lignes de cache à la fois. Par exemple, prenons le cas où une ligne de cache de 256 commence à l'adresse 64 et une autre ligne de cache commence à l'adresse 0. L'adresse 128 serait dans les deux lignes de cache ! Et cela poserait des problèmes lors des lectures, mais encore plus lors des écritures. C'est pour éviter ce genre de problèmes que les lignes de cache sont alignées avec la mémoire RAM dans tous les caches existants. L'alignement des lignes de cache est une chose que les programmeurs doivent parfois prendre en compte quand ils écrivent du code ultra-optimisé, destiné à des programmes demandant des performances extrêmes. Il arrive que les contraintes d'alignement posent des problèmes. Nous avions vu dans le chapitre sur le boutisme et l'alignement qu'il valait mieux gérer l'alignement des variables des structures de données, pour éviter les accès non-alignés avec le bus mémoire. La même chose est possible, mais pour l'alignement avec des lignes de cache. Typiquement, l'idéal est que, pour une structure de donnée, on puisse en mettre un nombre entier dans une ligne de cache. Ou alors, si la structure est vraiment grande, que celle-ci occupe un nombre entier de lignes de cache. Si ce n'est pas le cas, il y a un risque d'accès non-alignés, c'est à dire qu'une structure se retrouve à cheval sur deux lignes de cache, avec les défauts que cela implique. ===Le tag d'une ligne de cache=== Plus haut, nous avions dit que le cache mémorise, pour chaque ligne de cache, l'adresse RAM associée. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Mais du fait de l'organisation du cache en lignes de cache de grande taille, qui sont de plus alignées en mémoire, il faut nuancer cette affirmation. Le cache ne mémorise pas la totalité de l'adresse, ce qui serait inutile. L'alignement des lignes de cache en RAM fait que les bits de poids faible de l'adresse ne sont pas à prendre en compte pour l'association adresse-ligne de cache. Dans ces conditions, on mémorise seulement la partie utile de l'adresse mémoire correspondante, qui forme ce qu'on appelle le '''tag'''. Le reste de l'adresse indique quelle est la position de la donnée dans la ligne de cache. Par exemple, prenons le cas où le processeur gère des nombres entiers de 64 bits (8 octets) et des lignes de cache de 128 octets : chaque ligne de cache contient donc 16 entiers. Si le processeur veut lire ou écrire un entier bien précis, il doit préciser sa place dans la ligne de cache. Et ce sont les bits de l'adresse mémoire non-inclus dans le cache qui permettent de faire ça. En clair, une adresse mémoire à lire/écrire est interprété par le cache comme la concaténation d'un tag et de la position de la donnée dans la ligne de cache correspondante. [[File:Adressage d'un cache totalement associatif.png|centre|vignette|upright=2|Adressage d'un cache totalement associatif]] Le cache est donc une grande table de correspondance entre tags et lignes de cache. Lors d'un accès mémoire, le cache extrait le tag de l'adresse à lire ou écrire, et le compare avec les tags de chaque ligne de cache. Si une ligne contient ce tag, alors c'est que cette ligne correspond à l'adresse, et c'est un défaut de cache sinon. Lors d'un succès de cache, la ligne de cache est lue depuis le cache et envoyée à un multiplexeur qui sélectionne la donnée à lire dans la ligne de cache. Le fonctionnement est similaire pour une écriture : la donnée à écrire passe dans un démultiplexeur, qui envoie la donnée au bon endroit dans la ligne de cache sélectionnée. [[File:Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.png|centre|vignette|upright=2|Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.]] ===Le contenu d'une ligne de cache=== Dans ce qui va suivre, nous allons considérer que chaque ligne de cache mémorise son tag, les données de la ligne de cache proprement dit, et quelques bits de contrôle annexes qui varient suivant le cache considéré. [[File:Tag d'une ligne de cache.png|centre|vignette|upright=2|Tag d'une ligne de cache.]] Les caches modernes incluent de nombreux bits de contrôle, mais deux d'entre eux sont communs à presque tous les caches modernes : le bit ''Dirty'' et le bit ''Valid''. Le '''bit ''Valid''''' indique si la ligne de cache contient des données valides ou non. Si le bit ''Valid'' est à 0, la ligne de cache est en état valide, à savoir qu'elle contient des données et n'est pas vide. Par contre, si ce bit est à 1, la ligne de cache est invalide et son contenu ne peut pas être lu ou écrit. L'utilité de ce bit est qu'il permet d'effacer une ligne de cache très rapidement : il suffit de mettre ce bit à 0. Il existe des situations où le cache doit être effacé, on dit alors qu'il est invalidé. Une section de ce chapitre sera dédié à l'invalidation du cache. Le '''bit ''Dirty''''' indique qu'une ligne de cache a été modifiée. Par modifiée, on veut dire que le processeur a écrit dedans, qu'il a modifié la ligne de cache. Mais attention : si la donnée a été modifiée dans le cache, la modification n'est pas forcément propagée en mémoire RAM. Le bit ''dirty'' indique si c'est le cas, si l'écriture a été propagée en mémoire RAM. Il précise que la ligne de cache contient des données modifiées, alors que la RAM a des données initiales non-modifiées. Une ligne de cache avec un bit ''dirty'' à 1 est dite ''dirty'', par métonymie. Nous verrons cela en détail dans la section sur les caches ''write-back'' et ''write-through''. Les caches modernes ajoutent des '''bits de détection/correction d'erreur''' dans les bits de contrôle. Pour rappel, les codes de détection/correction d'erreur permettent de se prémunir contre des erreurs matérielles, qui corrompent les données stockées dans une mémoire, ici une mémoire cache. Ils ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Nous reviendrons dessus dans une section ultérieur de ce chapitre. Sur certains caches assez anciens, on pouvait transférer les lignes de caches morceaux par morceaux. Ces caches avaient des lignes de cache divisées en sous-secteurs, ces sous-secteurs étant des morceaux de ligne de cache qu'on pouvait charger indépendamment les uns des autres (mais qui sont consécutifs en RAM). Chaque secteur avait ses propres bits de contrôle, mais le tag était commun à tous les secteurs. [[File:Cache à secteurs.png|centre|vignette|upright=2.5|Cache à secteurs.]] : Dans ce qui va suivre, le terme "ligne de cache" désignera soit un bloc de données copiées depuis la RAM d'une taille de 64/128/256/... octets, soit la concaténation de ces données avec le tag et des bits de contrôle. Les deux définitions ne sont pas équivalentes, mais l'usage a entériné cet abus de langage. Et il faut avouer que cela rend les explications du chapitre plus simples. ==Les instructions de contrôle du cache== Plus haut, nous avions dit que le cache est totalement transparent du point de vue du programmeur. Le cache contient des copies de données en RAM, le programmeur n'a rien à faire pour utiliser le cache correctement. Mais la réalité est que pour des raisons diverses, des processeurs incorporent des '''instructions de contrôle du cache'''. Il s'agit d’instructions qui agissent sur le contenu du cache. Elles existent pour des raisons diverses qu'on détaillera plus bas, mais il s'agit globalement d'une question de performances ou de nécessité pour le système d'exploitation. ===Les instructions de préchargement=== La première instruction de contrôle du cache est une '''instruction de préchargement''', qui demande à charger un bloc de données dans le cache. Elle prend en opérande une adresse mémoire, et le contenu de cette adresse est chargé dans une ligne de cache. Bien sûr, des contraintes d'alignement sont à prendre en compte : on charge un bloc de la même taille qu'une ligne de cache, aligné en mémoire sur la taille du bloc, qui contient l'adresse. L'instruction de préchargement n'est utile que si l'instruction est exécutée bien avant que la donnée ne soit utilisée/lue/écrite. Cela permet de charger une donnée dans le cache à l'avance, d'où le nom de préchargement donné à cette technique. Mais les processeurs modernes gérent des techniques de préchargement automatique, qui ne requièrent pas d'instructions de préchargement. Le préchargement automatique et les instructions de préchargement sont deux solutions complémentaires, mais qui peuvent se marcher sur les pieds. Nous en reparlerons dans le prochain chapitre, qui sera dédié au préchargement automatique. Il faut noter que les instructions de préchargement peuvent être ignorées par le processeur. Sous certaines conditions, le processeur peut décider que l'instruction de préchargement ne sera pas exécutée. Par exemple, il ne va pas précharger une donnée déjà présente dans le cache. Ou encore, si le bus mémoire est occupé, il ne va pas exécuter le préchargement, par manque de ressources matérielles. ===Les instructions d'invalidation et de ''flush''=== Les instructions ''flush'' regroupent deux types d'instructions qui sont souvent utilisées en même temps. Il s'agit des instructions d'invalidation et de nettoyage (''clean''). Les deux termes proviennent de la terminologie ARM, il n'y a pas de terminologie standardisé pour les noms de ces instructions. Dans les grandes lignes, elles permettent de vider le cache, à savoir de rapatrier son contenu en RAM et de réinitialiser le cache à zéro. Elles sont utilisées par le système d'exploitation lors des commutations de contexte, à savoir quand on passe d'un programme à un autre. Elles sont aussi utilisées lors des appels systèmes et routines d'interruption/exception. L'idée est de vider le cache avant d'exécuter un nouveau programme ou une nouvelle routine. Le nouveau programme aura accès à un cache tout propre, les données de l'ancien programme auront été retirée du cache. Les '''instructions ''clean''''' recopient le contenu de la ligne de cache en RAM. Elles forcent la recopie immédiatement de la ligne de cache en mémoire RAM. Pour faire leur travail, elle vérifient si la ligne de cache a été modifiée, avant de la recopier en RAM. Et pour cela, ils vérifient le bit de contrôle ''dirty'', qui est mis à 1 après une première écriture. Si ce bit est à 0, alors pas besoin de recopier la ligne de cache : elle n'a pas été modifiée, la RAM a déjà la bonne copie. Mais s'il est à 1, le cache et la RAM n'ont pas le même contenu, la recopie s'exécute. Les '''instructions d'invalidation''' permettent d'invalider une ligne de cache, à savoir d'effacer son contenu. Nous verrons à quoi servent ces instructions dans la section sur les changement de processus. Invalider une ligne de cache est une opération optimisée : le cache n'est en réalité pas réellement effacé. À la place, le bit ''Valid'' de chaque ligne de cache est juste mis à 0. Il faut noter que l'invalidation efface les lignes de cache sans se préoccuper de leur contenu. Elle se moque qu'une ligne de cache contienne une donnée modifiée, ''dirty'' ou quoique ce soit : la ligne de cache est effacée, point. Il est possible d'invalider une ligne de cache en fournissant une adresse mémoire, mais il est aussi possible d'invalider le cache tout entier. Le choix entre les deux dépend du mode d'adressage de l'instruction d'invalidation. Parfois, il existe une instruction séparée pour invalider tout le cache, et une autre pour invalider une ligne de cache bien précise. Des instructions séparées sont parfois disponibles pour invalider les caches de données et d'instructions, parfois aussi la TLB (un cache qu'on verra dans quelques chapitres). Il est possible de n'invalider que le cache L1, voire le cache L2. Il faut noter que l'invalidation efface tout le cache, mais ne se préoccupe pas de vérifier si les données ont été modifiées dans le cache. Pour certains caches, comme le cache d'instruction, ce n'est pas un problème, vu qu'il est en "lecture seule". Mais pour les caches de données, les données modifiées sont perdues en cas d'invalidation. Heureusement, il existe des instructions d'invalidation qui fusionnent une instruction ''clean'' et une instruction d'invalidation. Il s'agit d''''instructions d'invalidation spéciales'''. ===Les instructions d'optimisation : instructions non-temporelles et écritures optimisées=== Les '''instructions mémoire non-temporelles''' contournent complètement le cache. Par exemple, une lecture peut lire une donnée, mais celle-ci ne sera pas chargée dans le cache, elle passe directement de la RAM vers les registres. Une section entière de ce chapitre sera dédiée au contournement du cache, à savoir aux situations où les accès mémoire doivent passer directement du processeur à la RAM sans passer par le cache. D'autres instructions assez rares incorporent des indications pour le cache. Par exemple, l'instruction ''load last'' des processeurs POWER PC implique que la donnée ne sera utilisée qu'une seule fois. Elle est donc chargée dans le cache, mais la ligne de cache est configurée de manière à être remplacée très rapidement, typiquement avec une valeur de LRU/LFU adéquate. La donnée est bien chargée dans le cache, au cas où elle doive être relue suite à une mauvaise prédiction de branchement ou autre, chose qu'une lecture non-temporelle (qui contourne le cache) ne fait pas. Des indications de ce type sont appelées des '''''cache hint'''''. L''''instruction ''flush''''' permet de préciser qu'une ligne de cache contient une donnée inutile, qui ne sera pas réutilisée par le programme. Pas besoin de la conserver dans le cache, elle peut laisser sa place à des données plus utiles. Or, sans indication, les algorithmes de remplacement d'une ligne de cache risquent de conserver cette donnée trop longtemps, ce qui entraine une certaine pollution du cache par des données inutiles. Une autre instruction est elle beaucoup plus importante : celle de '''pré-allocation sur écriture'''. Elle sert dans le cas où une ligne de cache est complétement écrite. Par exemple, imaginons qu'on veuille écrire dans une portion de mémoire. Si celle-ci n'est pas dans le cache, le processeur va charger une ligne de cache complète depuis la RAM, écrire dans la ligne de cache, puis recopier la ligne de cache modifiée en mémoire RAM. Une écriture en RAM demande donc de faire une lecture et une écriture. Mais les instructions de pré-allocation sur écriture permettent de prévenir qu'une ligne de cache sera intégralement écrite, et qu'il n'y a donc pas besoin de lire celle-ci depuis la RAM. Notons que l'instruction d'écriture qui suit n'est pas une écriture non-temporelle, vu que les données sont écrites dans la ligne de cache, qui est ensuite envoyée en mémoire RAM dès que nécessaire. De plus, les données écrites peuvent ensuite être relue depuis le cache si nécessaire. Enfin, certains processeurs MIPS incorporent une instruction pour modifier le tag d'une ligne de cache. Elles servent à optimiser les copies mémoire, à savoir quand on copie un bloc de données d'un endroit à un autre. L'idée est de charger le bloc de données dans le cache avec une instruction LOAD/PREFETCH, de modifier le tag pour qu'il pointe vers l'adresse à écrire, et de laisser faire le cache pour que l'écriture se fasse en RAM. Mais les contraintes pour utiliser cette instruction sont assez drastiques : les données doivent être alignées sur la taille d'une ligne de cache, le bloc de départ et d'arrivée (l'original versus la copie) ne doivent pas se recouvrir, etc. ==L'associativité des caches et leur adressage implicite== Lorsqu'on souhaite accéder au cache, il faut trouver quelle est la ligne de cache dont le tag correspond à l'adresse demandée. On peut classifier les caches selon leur stratégie de recherche de la ligne correspondante en trois types de caches : totalement associatifs, directement adressés (''direct mapped'') et associatifs par voie. ===Les caches totalement associatifs=== Avec les caches totalement associatifs, toute donnée chargée depuis la mémoire peut être placée dans n'importe quelle ligne de cache, sans aucune restriction. Ces caches ont un taux de succès très élevé, quand on les compare aux autres caches. [[File:Cache totalement associatif.png|centre|vignette|upright=2|Cache totalement associatif.]] Concevoir un cache totalement associatif peut se faire de deux grandes manières différentes. La première consiste tout simplement à combiner une mémoire associative avec une mémoire RAM, en ajoutant éventuellement quelques circuits annexes. La mémoire associative mémorise les tags, alors que la mémoire RAM mémorise les données de la ligne de cache, éventuellement avec quelques bits de contrôle. La ligne de cache est stockée à une adresse A dans la mémoire RAM et son tag est stocké à la même adresse, mais dans la mémoire CAM. Ce faisant, quand on envoie le tag à la mémoire CAM, elle renvoie l'adresse de la ligne de cache dans la mémoire RAM. Cette adresse est alors envoyée directement sur le bus d'adresse de la RAM, et la lecture est effectuée automatiquement. Il faut ajouter quelques circuits annexes pour garantir que les écritures se passent correctement dans les deux mémoires, mais rien de bien terrible. [[File:Cache fabriqué avec une mémoire associative et une RAM.png|centre|vignette|upright=3|Cache fabriqué avec une mémoire associative et une RAM]] Il est cependant possible d'optimiser un tel cache, en fusionnant la mémoire CAM et la mémoire RAM, afin d'éliminer des circuits redondants. Pour comprendre pourquoi, rappelons que les mémoires CAM sont composées d'un plan mémoire, d'un paquet de comparateurs et d'un encodeur. Quant à la mémoire RAM, elle est composée d'un décodeur connecté au plan mémoire. En mettant une CAM suivie d'une RAM, on a un encodeur dont l'entrée est envoyée à un décodeur. [[File:Cache totalement associatif naif.png|centre|vignette|upright=3|Cache totalement associatif naif]] Or, le décodeur réalise l'opération inverse de l'encodeur, ce qui fait que mettre les deux composants à la suite ne sert à rien. On peut donc retirer l'encodeur et le décodeur, et envoyer directement les résultats des comparateurs sur les entrées de commande du plan mémoire de la RAM. [[File:Cache totalement associatif optimisé.png|centre|vignette|upright=2|Cache totalement associatif optimisé]] Avec cette méthode, les circuits du cache ressemblent à ce qui illustré ci-dessous. Le tag est envoyé à chaque ligne de cache. Le tag envoyé est alors comparé avec le Tag contenu dans chaque ligne de cache, comme c'est le cas sur les mémoires associatives. Si une ligne de cache matche avec le tag envoyé en entrée, la ligne pour laquelle il y a eu une égalité est alors connectée sur les lignes de bit (''bitlines''). Cela est réalisé par un circuit commandé par le comparateur de la ligne de cache. Il ne reste plus qu'à sélectionner la portion de la ligne de cache qui nous intéresse, grâce à un paquet de multiplexeurs. Cela permet d'effectuer une lecture ou écriture, mais il faut aussi préciser si il y a eu un défaut de cache ou un succès. Un succès de cache a lieu quand au moins des comparaisons est positive, alors que c'est un défaut de cache sinon. En clair, détecter un succès de cache demande juste de connecter une porte OU à plusieurs entrées à tous les comparateurs. [[File:Organisation générale d'un cache totalement associatif.png|centre|vignette|upright=2|Organisation générale d'un cache totalement associatif.]] ===Les caches directement adressés=== Les caches directement adressés peuvent être vus comme un cache totalement associatif auquel on aurait ajouté des restrictions assez drastiques. Plus haut, on a vu qu'un cache totalement adressé est équivalent à la combinaison d'une CAM avec une RAM. La mémoire CAM prend en entrée un Tag et traduit celui-ci en une adresse qui commande la mémoire RAM interne au cache. Dans ce qui suit, l'adresse interne au cache sera appelé l''''indice''' pour éviter toute confusion. [[File:Cache hash table - 2.png|centre|vignette|upright=2|Fonctionnement interne du cache, expliquée sous forme abstraite, en utilisant la notion d'indice interne au cache.]] Les caches directement adressés cherchent à remplacer la mémoire CAM par un circuit combinatoire. Ce circuit traduit le Tag en indice, mais est beaucoup plus simple qu'une mémoire CAM. Mais qui dit circuit plus simple dit circuit plus limité. Un circuit combinatoire n'est pas aussi versatile que ce qui est permis avec une mémoire CAM. En conséquence, une restriction majeure apparait : toute adresse mémoire est associée dans une ligne de cache prédéfinie, toujours la même. L'association entre ligne de cache et adresse mémoire est faite par le circuit combinatoire, et ne peut pas changer. Les concepteurs de caches s'arrangent pour que des adresses consécutives en mémoire RAM occupent des lignes de cache consécutives, par souci de simplicité. Tout se passe comme suit la mémoire RAM était découpés en blocs de la même taille que le cache. La première adresse du bloc est associée à la première ligne de cache (celle d'indice 0), la seconde adresse est associée à la seconde adresse du_ bloc, et ainsi de suite. Le tout est illustré ci-dessous. [[File:Cache adressé directement.png|centre|vignette|upright=2|Cache adressé directement.]] Avec cette contrainte, le circuit de traduction de l'adresse en adresse mémoire pour la RAM interne au cache est drastiquement simplifié, et disparait même. Une partie de l'adresse mémoire sert à indiquer la position de la donnée dans le cache, le reste de l'adresse sert encode le tag et la position de la donnée dans le ligne de cache. [[File:Cache line.png|centre|vignette|upright=2|Adresse d'une ligne de cache sur un cache adressé directement.]] Un cache directement adressé est conçu avec une RAM, un comparateur, et un paquet de multiplexeurs. En général, la mémoire RAM stocke les lignes de caches complète. Il arrive que l'on utilise deux mémoires RAM : une pour les tags et une pour les données, mais cette technique augmente le nombre de circuits et de portes logiques nécessaires, ce qui réduit la capacité du cache. L'index à lire/écrire est envoyé sur l'entrée d'adresse de la RAM, la RAM réagit en mettant la ligne de cache sur sa sortie de donnée. Sur cette sortie, un comparateur compare le tag de la ligne de cache lue avec le tag de l'adresse à lire ou écrire. On saura alors si on doit faire face à un défaut de cache. Ensuite, un multiplexeur récupère la donnée à lire/écrire. [[File:Direct mapped cache - french.png|centre|vignette|upright=2|Cache directement adressé.]] L'accès à un cache directement adressé a l'avantage d'être très rapide vu qu'il suffit de vérifier une seule ligne de cache : celle prédéfinie. Mais ces caches ne sont cependant pas sans défauts. Vu que le cache est plus petit que la mémoire, certaines adresses mémoires se partagent la même ligne de cache. Si le processeur a besoin d’accéder fréquemment à ces adresses, chaque accès à une adresse supprimera l'autre du cache : tout accès à l'ancienne adresse se soldera par un défaut de cache. Ce genre de défauts de cache causés par le fait que deux adresses mémoires ne peuvent utiliser la même ligne de cache s'appelle un '''défaut par conflit''' (''conflict miss''). Les défauts par conflit n'existent pas sur les caches totalement associatifs. En conséquence, le taux de succès des caches directement adressés est assez faible comparé aux autres caches. [[File:Cache Block Basic Conflict.svg|centre|vignette|upright=1.5|Exemple de ''Conflict Miss''.]] ===Les caches associatifs par voie=== Les caches associatifs par voie sont un compromis entre les caches directement adressés et les caches totalement associatifs. Pour simplifier, ces caches sont composés de plusieurs caches directement adressés accessibles en parallèle, chaque cache/RAM étant appelé une '''voie'''. Avec ces caches, toute adresse mémoire en RAM est associée à une ligne de cache dans chaque voie. [[File:Cache associatif par voie.png|centre|vignette|upright=2|Cache associatif par voie.]] Le schéma ci-dessous compare un cache directement adressé et un cache associatif à deux voies. On voit que chaque adresse est associée à une ligne de cache bien précise avec un cache directement dressé, et à deux lignes de cache avec un cache associatif à deux voies. L'adresse sera associée à 4 lignes de cache sur un cache associatif à 4 voies, à 8 lignes pour un cache à 8 voies, etc. L'ensemble des lignes de cache associées à une adresse est appelé un '''ensemble'''. [[File:Cache Fill.svg|centre|vignette|upright=2|Comparaison entre un cache directement adressé et un cache associatif à deux voies.]] Sur ces caches, toute adresse est découpée en trois parties : un tag, un index, et un décalage, comme sur les caches directement adressés. Comme vous pouvez le voir, l'organisation est identique à celle d'un cache totalement associatif, à part que chaque ensemble tag-ligne de cache est remplacé par une mémoire RAM qui en contient plusieurs. [[File:Implémentation d'un cache associatif par voie.png|centre|vignette|upright=2|Implémentation d'un cache associatif par voie.]] Le risque de conflits d'accès au cache est donc réduit sur un cache associatif à plusieurs voies, et il est d'autant plus réduit que le cache a de voies. Par contre, leur conception interne fait qu'ils ont un temps d'accès légèrement élevé que les caches directement adressés. Les caches associatifs par voie ont donc un taux de succès et un temps d'accès intermédiaire, situé entre les caches directement adressés et totalement associatifs. Ils sont une sorte de compromis entre réduction des défaut par conflits d'accès au cache et temps d'accès, et complexité des circuits. ==Les optimisations des caches associatifs par voie== Les caches partiellement associatifs regroupent les caches associatifs par voie et directement adressés, ainsi que leurs variantes. En clair : tous les caches qui ne sont pas totalement associatifs. Ils peuvent être optimisés de nombreuses manières, que ce soit pour gagner en performance ou pour économiser de l’énergie. Dans cette section, nous allons voir quelles sont ces optimisations. ===Les caches pseudo-associatifs=== Les caches adressés par voie contiennent une mémoire SRAM par voie. En théorie, les voies sont accédées en parallèles, en même temps, afin de voir si l'on a un succès de cache ou un défaut. Les '''caches pseudo-associatifs''' sont identiques aux caches associatifs par voie, si ce n'est qu'ils vérifient chaque voie une par une. Ils ont été utilisés sur des processeurs commerciaux, un exemple étant l'IBM 370. Là encore, on perd en performance pour gagner en consommation d'énergie. Le temps d'accès dans le meilleur des cas est plus faible pour les caches pseudo-associatifs, mais le pire des cas teste tous les caches avant de tomber sur le bon. Les performances sont donc réduites. Mais la consommation énergétique est meilleure, vu qu'on ne vérifie pas forcément toutes les voies en parallèle. On teste la première voie, éventuellement la seconde, peut-être la troisième, etc. Mais dans le cas général, on ne teste qu'une partie des voies, pas toutes, ce qui donne un gain en termes d'énergie. L'implémentation de caches de ce genre demande que l'on parcoure les voies une par une, en commençant de la première jusqu'à la dernière. Pour cela, un simple compteur suffit. Suivant la valeur du compteur, la voie associée est activée puis accédée. Toute la complexité revient à ajouter un circuit qui prend la valeur du compteur, et active la voie associée, lance un accès mémoire dessus. Vu que les voies sont chacune des caches ''direct mapped'', il suffit pour cela de geler les entrées d'adresse, soit en les déconnectant, soit en utilisant du ''clock gating'' ou de l'évaluation gardée. Les détails d'implémentation, non-cités ici, varient selon le cache. ===La prédiction de voie=== Pour réduire le temps d'accès des caches pseudo-associatifs, certains chercheurs ont inventé la '''prédiction de voie''', qui consiste à faire des paris sur la prochaine voie accédée. L'idée est d'accéder à la voie qui contient la donnée voulue du premier coup, en lisant celle-ci en priorité. Dans son implémentation la plus simple, le cache reste un cache pseudo-associatif. Lors d'un accès au cache, les voies sont toutes parcoures une par une. Par contre, les voies ne sont donc pas parcourues de la première vers la dernière, mais dans un ordre différent. Cette technique permet de mettre en veille les voies sur lesquels le processeur n'a pas parié, ce qui permet de diminuer la consommation énergétique du processeur. C'est plus efficace que d'aller lire plusieurs données dans des voies différentes et de n'en garder qu'une. L'implémentation est assez simple : il suffit d'ajouter un circuit de prédiction de voie,relié au compteur de voie. Une amélioration de la technique fait fonctionner le cache comme un intermédiaire entre cache pseudo-associatif et associatif par voies. L'idée est de chercher la voie prédite en premier, puis de chercher dans toutes les voies en parallèle en cas de défaut de cache. Au lieu d'attendre que les comparaisons de tags donnent leur résultat, le processeur sélectionne automatiquement une voie et configure les multiplexeurs à l'avance. Si le processeur ne se trompe pas, le processeur accède à la donnée plus tôt que prévu. S'il se trompe, le processeur annule la lecture effectuée en avance et recommence en faisant un accès en parallèle aux autres voies. Le compromis entre performance et consommation d'énergie est alors différent. On économise de l'énergie par rapport à un cache associatif par voie, au prix d'une petite perte de performance (doublement des temps d'accès). Mais par rapport à un cache pseudo-associatif, l'économie d'énergie est bien moindre, au prix d'un gain en performance assez manifeste. Prédire quelle voie sera la bonne est assez simple. En vertu du principe de localité, les accès futurs ont des chances de tomber dans les voies les plus fréquemment utilisées ou dans celle plus récemment utilisée. Il suffit de retenir la voie la plus récemment accédée dans un registre, qui sera utilisée comme prédiction. Pour vérifier que la prédiction est correcte, il suffit de comparer le registre et le résultat obtenu après vérification des tags. Cependant, on peut complexifier l'implémentation pour prendre en compte l'adresse à lire/écrire, l'instruction à l'origine de l'accès mémoire ou tout autre paramètre utile. Par exemple, des instructions différentes ont tendance à aller chercher leurs données dans des ensembles différents et la voie à choisir n'est pas la même. Pour cela, il suffit d'utiliser un cache pour stocker la correspondance instruction - voie. Pour plus de simplicité, la mémoire cache des prédictions est parfois remplacée par une RAM, qui est adressée : * soit par le program counter de l'instruction à l'origine de l'accès (en réalité, seulement quelques bits de poids faible de l'adresse) ; * soit par l'adresse à accéder (là encore, quelques bits de poids faible) ; * soit (pour les modes d'adressage qui utilisent un registre de base et un décalage) par un XOR entre les bits de poids faible de l'adresse de base et le décalage ; * soit par autre chose. ===La mise en veille sélective des voies=== Les caches associatifs ont tendance à utiliser beaucoup d'énergie, même quand on n'y accède pas. Aussi, certains processeurs détectent quand le cache est peu utilisé et en profitent pour mettre en veille les voies inutilisées. Vous vous demandez certainement ce qui se passe quand une donnée à lire/écrire est dans une voie désactivée. La réponse est que le cache détecte cette situation, car elle déclenche un succès de cache. Les ''tags'' ne sont en effet pas désactivés, seules les données sont mises en veille. L'implémentation est plus simple sur les caches qui séparent les tags et les données dans deux RAM différentes. Cette optimisation marche surtout sur les gros caches, qui ont des chances d'avoir une portion significative d’inutilisée (pas assez de données pour les remplir), donc généralement les caches L3/L4. Par exemple, les processeurs d'Intel de microarchitecture Ivy Bridge disposent d'un cache de 8 mébioctets à 16 voies, qu'ils peuvent faire passer à 512 kibioctets si le besoin s'en fait sentir. Quand ces processeurs détectent une faible activité, ils mettent en veille 14 voies et n'en gardent que 2 d'actives. Évidemment, les 14 voies sont vidées avant d'être mises en veille, afin qu'une aucune donnée ne soit perdue. ===Les caches ''skew-associative''=== Vous aurez remarqué que dans une voie, les lignes sont accédées en adressage direct : les défauts par conflit sont possibles sur un cache associatif par voie. Pour éviter cela, certains chercheurs ont créé des '''caches ''skew associative''''' (ou associatifs à biais). Pour faire simple, les index des lignes de cache subissent un petit traitement avant d'être utilisés. Le traitement en question est différent suivant la voie de destination, histoire que deux adresses mémoires avec des index identiques donnent des index différents après traitement. Le traitement en question est souvent une permutation des bits de l'index, qui est différente suivant la voie prise, ou un simple XOR avec un nombre qui dépend de la voie. [[File:Implémentation d'un cache skew associative.jpg|centre|vignette|upright=2|Implémentation d'un cache skew associative.]] ==Les caches splittés (''phased caches'')== Dans cette section, nous allons voir les '''caches splittés''' (''phased caches''), qui sont une variante des caches ''direct-mapped'', dans lequel le cache est accédé en deux étapes consécutives. Il ne s'agit pas des caches pipelinés, que nous verrons dans le chapitre sur les processeurs pipélinés, mais laissons cela à plus tard. Il est possible d'appliquer la même méthode sur un cache associatif par voie, mais il y a des méthodes plus simples, qui permettent là aussi d’accéder au cache en plusieurs étapes consécutives. L'idée est de scinder le cache en deux : une mémoire pour les tags, une autre pour les données de la ligne de cache. Les bits de contrôle peuvent être mis dans l'une ou l'autre SRAM, mais ils sont souvent mis dans la RAM pour les tags. En faisant cela, quelques optimisations deviennent possibles, afin de réduire la consommation énergétique en contrepartie d'une perte de performance. La technique s'implémente différemment pour les caches totalement associatifs et partiellement associatifs. Les caches totalement associatifs splittés sont ceux formés en combinant un cache associatif avec une CAM et une RAM combinée. On envoie l'adresse à lire/écrire à la mémoire associative, elle répond en envoyant une adresse à la mémoire RAM. L'accès se fait donc en deux temps, avec l'adresse dans la RAM comme intermédiaire. Il est possible de séparer physiquement les deux étapes en insérant un registre entre la CAM et la RAM, ce qui permet aussi de pipeliner l'accès. Mais c'est rarement fait en pratique, car le cout en circuit d'une mémoire CAM est trop important. L'équivalent pour un cache totalement associatif optimisé, sans CAM et RAM séparée, est trop gourmande en interconnexions pour être implémentée. Les caches totalement associatifs splittés sont donc très rares, l'auteur ne connait aucun exemple de processeur avec un tel cache. Il existe une technique équivalente pour les caches ''direct-mapped'', mais elle demande une certaine modification du cache. Dans les caches ''direct-mapped'' non-splittés, on trouve une mémoire SRAM dont chaque mot mémoire contient une ligne de cache entière, tag inclus. Dans leurs versions splittés, la SRAM est séparée en deux : une pour les tags, une autre pour les données. Précisons qu'il s'agit bien de deux mémoires SRAM adressables. L'adresse à laquelle accéder est envoyée à la SRAM des tags, puis ensuite à la SRAM des données si besoin. L'idée est d’accéder aux tags pour déterminer s'il y a un succès de cache ou un défaut, et ensuite d'accéder aux données. On n’accède pas aux données en parallèle des tags. Faire cela est évidemment plus lent. En cas de défaut de cache, le temps d'accès est similaire : le tag ne correspond pas, on n'accède pas à la SRAM pour les données. Par contre, vu qu'on n'a pas activé la SRAM pour les données, on économise un peu d'énergie, ce qui réduit la consommation d'énergie. En cas de succès de cache, on accède à la SRAM pour les tags, puis à celle pour les données. Pas d'économie d'énergie à l'horizon, sans compter que le temps d'accès augmente : on accède au cache en deux étapes au lieu de faire les deux accès en parallèle. [[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]] Précisons cependant que ce design peut avoir deux avantages en termes de performance. Premièrement, le temps d'accès au cache est légèrement amélioré en cas de défaut de cache. En effet, la SRAM des tags est assez petite, idem pour celle des données. Leur temps d'accès est donc plus faible que pour une grosse SRAM contenant données et tags. Le gain en temps d'accès est donc un avantage, qui ne se manifeste surtout en cas de défaut de cache. Un autre avantage est que l'accès au cache se pipeline plus facilement, ce qui fait qu'on peut effectuer plusieurs accès simultanés au cache. Mais nous verrons cela dans quelques chapitres. ===L'exemple des processeurs 386 et du contrôleur de cache 82385=== Il est important de noter que la séparation entre tags et RAM peut être telle que les deux ne sont pas sur la même puce de silicium ! Voire que les deux sont séparés du processeur ! C'était le cas quand les mémoires caches ont été introduites sur les processeurs grand public, notamment sur les premiers processeurs Intel. La miniaturisation n'avait pas avancé au point où placer un cache dans le processeur était possible. Sur le processeur 386 d'Intel, le cache était un cache splitté, séparé du processeur. Concrètement, le processeur i386 était couplé à un contrôleur de cache Intel 82385 et une mémoire SRAM. Le 82385 contenait les ''tags'' et les bits de contrôle, la SRAM contenait les données, les lignes de cache. Un point important est que les lignes de cache faisaient seulement 32 bits/4 octets, pas plus ! On était loin des lignes de cache actuelles, faisant 64 octets/512 bits. Mais c'était beaucoup plus pratique, vu que le bus système faisait 32 bits de large, idem pour l'interface avec le processeur. Pour intégrer un cache facultatifs (certains ordinateurs 'en passaient). Le schéma ci-dessous montre comment le cache s'intégrait avec le bus système. Le 82385 surveillait ce qui se passait sur le bus et répondait à la place de la RAM pour certaines lectures. Pour le bus de commande, le cache servait d'intermédiaire : il recevait les commandes et et les filtrait suivant les succès/défauts de cache. En cas de succès de cache, les commandes de lecture n'étaient pas envoyées à la mémoire RAM. Les adresses étaient transmises à la fois au cache et au bus système (avec un registre entre le bus système et le processeur). Le bus de donnée était lui connecté à la mémoire SRAM et au processeur, avec des MUX/DEMUX pour faire le choix de la source des lectures. Le cache était un cache ''write through''. [[File:Controleur de cache 82385 pour l'Intel 386.png|centre|vignette|upright=2.5|Contrôleur de cache 82385 pour l'Intel 386]] Le 82385 pouvait commander soit un cache ''direct mapped'', soit associatif à deux voies. La différence entre les deux est que le 82385 était relié à une mémoire SRAM avec un cache ''direct mapped'', deux SRAM pour deux voies. Pour avoir un cache associatif à deux voies, le 82385 devrait gérer deux signaux ''chip select'' pour activer chaque SRAM/voie suivant les besoins. Il avait précisément quatre signaux CS : deux par SRAM, un pour les lectures, un pour les écritures. Notons que les SRAM utilisées devaient être des SRAM avec un bus de données de 32 bits, des SRAM 16 ou 8 bits ne fonctionnaient pas. Le 82385 rajoutait 4 sorties, pour masquer chaque octet dans ces 32 bits. Le choix entre les deux était le fait d'une entrée : la mettre à 0 indiquait un cache ''direct mapped'', la mettre à 1 forçait un cache à deux voies. Il gérait aussi les accès mémoire non-cacheable, à savoir des accès mémoire qui ne doivent pas être pris en compte par le cache. Pour cela, il avait une entrée NCA (''Non Cacheable Access'') : le cache était "désactivé" quand cette entrée était à 1. C'est un peu une sorte de ''chip select'' pour le 82385, limitée aux accès mémoire. Cette entrée permettait de programmer des intervalles d'adresse auxquels ne pas répondre, en utilisant des circuits de décodage d'adresse adaptés. Il avait aussi une entrée X16, qui permettait d'identifier les accès soit à un composant 16 bits, soit à une entrée-sortie. De tels accès ne doivent pas être mis en cache, ce qui était garanti par cette entrée. Pour rappel, le processeur utilisait un espace d'adressage séparé pour les entrées-sorties, et utilisait donc un bit IO. Ce bit IO pouvait être utilisé pour déterminer le bit X16, qui inhibe l'action du cache. Cette surveillance du bus permettait de gérer une forme limitée de cohérence des caches par invalidation. Dès que le 82385 détectait une prise de contrôle du bus par autre chose que le processeur, il surveillait les adresses transmises sur le bus. En cas de succès de cache, la ligne de cache associée était invalidée. Au-delà de ca, le 82385 avait une entrée FLUSH, qui ordonnait une invalidation totale du cache. Si cette entrée est mise à 1, toutes les lignes de cache sont invalidées. Les ''tags'' sont marqués comme invalides, mais les lignes de cache elles-mêmes ne sont pas touchées. Le 82385 pouvait être intégré dans un système à deux processeurs, voire plus. Pour cela, chaque processeur avait son propre 82385 et sa SRAM rien qu'à lui. Il n'y avait pas de cache partagé entre les deux processeurs. Par contre, les deux caches étaient reliés au même bus système. Pour qu'ils ne se marchent pas sur les pieds, il y avait des circuits d'arbitrage pour gérer l'accès au bus. Un des deux 82385 était mis en mode maitre, l'autre était en mode esclave. Le 82385 maitre pouvait prendre le contrôle du bus, le 82385 esclave devait demander l'autorisation au premier pour accéder au bus système. ===L'exemple des processeurs Intel de microarchitecture ''Broadwell''=== Un autre exemple est celui du cache L4 des processeurs Broadwell et de quelques processeurs séparés. Ces processeurs ont une organisation en ''chiplet'' où le processeur incorpore plusieurs puces séparées : une puce pour le processeur proprement dit, une puce nommée ''Crystal Well'' pour le cache L4, et une puce IO pour la communication avec la RAM et la carte mère. Le processeur incorporait un cache L4 de 128 mébioctets, composé de mémoire eDRAM, qui était dispersé entre ''Crystal Well'' et les autres puces. Les données du cache L4 étaient dans ''Crystal Well'', alors que les Tags étaient soit dans le processeur lui-même, soit dans la puce IO ! La puce ''Crystal Well'' était une mémoire DRAM adressable tout ce qu'il y a de plus basique, avec cependant quelques optimisations notables. Par exemple, elle avait deux bus séparés pour l'écriture et la lecture. De plus, elle avait une organisation interne avec 128 banques, contre moins d'une dizaine pour la DDR de l'époque et environ 32 banques pour la DDR5 moderne. Elle contenait aussi quelques circuits pour gérer son rôle de mémoire cache, mais rien en ce qui concerne la gestion des tags eux-mêmes. Sur les processeurs de microarchitecture ''Broadwell'', les tags étaient placés dans le CPU et précisément dans le cache L3. À chaque accès mémoire au cache L3, les tags du cache L4 étaient consultés en parallèle. De fait, l'accès au cache L4 était assez rapide, malgré le fait que les données étaient dans une puce à part. Ajoutons à cela que le processeur et ''Crystal Well'' n'avaient pas la même finesse de gravure ni la même technologie de fabrication. Les tags étaient implémentés avec de la SRAM contre la DRAM pour les données, ce qui fait que la consultation des tags était plus rapide que l'accès aux données. Par la suite, dans certains CPU de microarchitecture ''skylake'', les tags ont été déplacés en-dehors du processeur pour finir dans le contrôleur mémoire. En faisant cela, le cache L4 pouvait être utilisé par autre chose que le processeur, et notamment par la carte graphique intégrée au CPU. Avec ''broadwell'', le fait que les tags étaient consultés en cas d'accès au L3 empêchait au GPU intégré de consulter le cache L4. Mais en déplaçant les tags dans le contrôleur mémoire, ce n'est plus le cas vu que la carte graphique a aussi accès au bus mémoire. Par contre, le temps d'accès augmente comparé à la solution précédente. On n'accède pas aux tags du L4 en parallèle du L3 : à la place, il faut consulter les tags du L3, détecter un défaut de cache L3, et ensuite accèder aux tags. ===Les caches RAM-configurables=== Un autre avantage des caches splittés est qu'on peut les modifier pour servir à la fois de mémoire cache, mais aussi de ''local store'', de mémoire RAM de petite taille. Le fonctionnement est assez simple à comprendre. Lors d'un accès au cache, on accède aux tags, puis à la RAM interne au cache. Lors d'un accès au ''local store'', on contourne l'accès au tags et on accède à la RAM interne au cache directement. Il s'agit de la technique du '''cache RAM-configurable''. L'usage de cache RAM-configurable est fréquent sur les cartes graphiques récentes, qui incorporent un ou plusieurs processeurs multicoeurs, dont le cache L1 de données est un cache RAM-configurable. [[File:Hydride cache - local store.png|centre|vignette|upright=2.0|Hydride cache - local store]] ===La compression de cache=== Une autre optimisation permise par les ''phased caches'' est l'implémentation de techniques de '''compression de cache''', qui visent à compresser des lignes de cache. L'intérêt est qu'on peut stocker plus de données dans le cache, à capacité égale. L'inconvénient est qu'on doit compresser/décompresser les lignes de cache, ce qui demande un circuit en plus et allonge les temps d'accès. En effet, le temps mis pour compresser/décompresser une ligne de cache s'ajoute au temps d'accès. Aussi, la compression de cache sert surtout pour les caches de bas niveau dans la hiérarchie mémoire, les gros caches aux temps d'accès assez longs. Une première technique, assez simple à implémenter et peu couteuse en circuit, est celle de la '''compression des lignes de cache nulles'''. Elle compresse uniquement les lignes de cache qui ne contiennent que des zéros. L'idée est qu'on ajoute, dans la mémoire des tags, un bit de contrôle pour chaque ligne de cache appelé le bit ''null''. Il indique si la ligne de cache ne contient que des zéros. Quand on lit une ligne de cache, la mémoire des tags est accédée et on vérifie le bit ''null'' : s'il vaut 1, on n'accède pas à la mémoire cache de données et un multiplexeur envoie un zéro sur le port de lecture. Le bit ''null'' est fixé lors de l'écriture d'une ligne de cache : elle passe dans un comparateur avec zéro relié à la mémoire des tags. La comparaison avec zéro peut se faire en parallèle de l'écriture ou avant (dans ce cas, on n'écrit pas la ligne de cache dans le cache). Les autres techniques de compression de cache permettent de compresser autre chose que des lignes de cache nulles. L'idée est qu'une ligne de cache physique peut par moment mémoriser plusieurs lignes de caches compressées. Par exemple, prenons un cache dont les lignes de cache font 64 octets. Il est possible de compresser deux lignes de cache pour qu'elles fassent chacune 32 octets, et les stocker dans une seule ligne de cache. Les deux lignes de cache auront des tags différents, mais pointeront sur la même ligne de cache physique. Et cela demande d'utiliser un ''phased cache'' dont la mémoire pour les tags est plus grande que la mémoire pour les données. Il n'y a donc plus une bijection entre tags et ligne de cache, mais une relation surjective. Chose qui n'est possible qu'avec un ''phased cache''. De plus, des bits de contrôles associés à chaque ''tag'' indiquent où se trouvent les lignes de cache compressées dans la ligne de cache : est-ce que c'est les 32 octets de poids fort ou de poids faible ? [[File:Compression de cache.png|centre|vignette|upright=2|Compression de cache]] Il ne semble pas que les techniques de compression de cache soient implémentées sur les processeurs modernes. Aucun n'utilise de compression de cache, à ma connaissance. Il faut dire que les techniques connues sont de mauvais compromis : le temps d'accès du cache augmente beaucoup, le cout en circuit pourrait être utilisé pour un cache non-compressé mais plus grand. Et notons que la compression de cache ne marche que si les données peuvent se compresser. Si ce n'est pas le cas, une partie de la mémoire des tags est inutilisée. Une revue de la littérature académique sur la compression de cache est disponible via ce lien, pour les curieux : * [https://inria.hal.science/hal-03285041 Understanding Cache Compression, par Carvalho et Seznec]. ==L'adressage physique ou logique des caches== Le cache utilise les adresses à lire/écrire pour déterminer s'il a une copie de la donnée en son sein. Mais l’interaction entre caches et mémoire virtuelle donne lieu à un petit problème : l'adresse utilisée est-elle une adresse virtuelle/logique ou physique ? La réponse varie suivant le processeur : certains caches utilisent l'adresse virtuelle, tandis que d'autres prennent l'adresse physique. On parle de cache '''virtuellement tagué''' dans le premier cas et de cache '''physiquement tagué''' dans le second. {| |[[File:Cache tagué virtuellement.png|vignette|Cache tagué virtuellement.]] |[[File:Cache tagué physiquement.png|vignette|Cache tagué physiquement.]] |} ===L'accès à un cache physiquement/virtuellement tagué=== La manière d'accéder à un cache dépend de s'il est virtuellement ou physiquement tagué. Il faut utiliser l'adresse virtuelle pour les premiers, physique pour les seconds. Avec un cache virtuellement tagué, l'adresse logique peut être envoyée directement au cache. La MMU ne traduit les adresses que s'il faut accéder à la mémoire RAM. Ces caches sont donc plus rapides. Avec un cache physiquement tagué, le processeur doit traduire l'adresse logique en adresse physique dans la MMU, avant d'accéder au cache. La traduction d'adresse se fait soit en accédant à une table des pages en mémoire RAM, soit en accédant à un cache spécifiquement dédié à accélérer la traduction d'adresse, la TLB (''Translation Lookaside Buffer''). Dans la quasi-totalité des cas, la traduction d'adresse passe par la TLB, ce qui fait qu'elle est raisonnablement rapide. Toujours est-il que chaque accès au cache demande d'accéder à la TLB et de faire la traduction d'adresse avant d'accéder au cache. L'accès est donc plus lent que sur les caches virtuellement tagués, où les accès sont plus directs. [[File:Virtual and Physical addressing.svg|centre|vignette|upright=2|Cache tagué virtuellement versus physiquement tagué.]] ===Les défauts des caches virtuellement tagués=== Les caches physiquement tagués sont moins rapides que les caches virtuellement adressés. Pourtant, les caches virtuellement tagués sont peu fréquents sur les processeurs modernes. Et la raison est assez intéressante : c'est une question d'adresses homonymes et synonymes. ====Les droits d'accès doivent être vérifiés lors d'un accès au cache==== Un premier problème est que la protection mémoire est compliquée avec de tels caches. Rappelons que certaines portions de mémoire sont accessibles seulement en lecture, ou sont interdites en écriture, sont inexécutables, etc. Ces droits d'accès sont gérés par la MMU, qui vérifie pour chaque accès mémoire que l'accès est autorisé. En bypassant la MMU, l'accès au cache virtuellement tagué ne permet pas de faire ces vérifications. Il est possible de charger une donnée en lecture seule dans le cache, mais d'y faire des accès en écriture pour les accès ultérieurs. Les solutions à cela sont multiples. La première consiste à consulter la MMU en parallèle de l'accès au cache. L'accès au cache est alors réalisé de manière spéculative, et est ensuite confirmé/annulé une fois que la MMU a rendu son verdict. Les performances du cache restent alors les mêmes : l'accès à la MMU se fait en parallèle de l'accès au cache, pas avant. Une autre solution est d'ajouter les droits d'accès en question dans la ligne de cache, dans les bits de contrôle situés après le Tag. Chaque accès au cache récupère ces bits de contrôle et vérifie si l'accès est autorisé. L'inconvénient est que les lignes de cache deviennent plus longues, les droits d'accès sont dupliqués entre MMU et cache. Mais si le budget en transistor suit, ce n'est rien d'insurmontable. ====Les adresses homonymes perturbent la gestion du cache==== Pour rappel, une adresse logique homonyme correspond à plusieurs adresses physiques différentes. Elles surviennent quand chaque programme a son propre espace d'adressage. Dans ce cas, une adresse logique correspondra à une adresse physique différente par programme.Une autre manière de voir les choses est qu'il y a en réalité deux adresses homonymes, qui ont la même valeur, mais appartiennent à des espaces d'adressage différentes. Et c'est cette seconde interprétation que nous allons utiliser. Les caches doivent gérer ces adresses homonymes et faire en sorte que la lecture/écriture d'une adresse homonyme se fasse à la bonne adresse physique, dans la bonne ligne de cache. Et autant un cache physiquement tagué n'a aucun problème avec ça, vu qu'il ne gère que des adresses physiques, autant des problèmes surviennent avec les caches virtuellement tagués. Le problème est que les caches virtuellement tagués doivent faire la différence entre deux adresses homonymes de même valeur. Pour corriger ces problèmes, il existe deux grandes méthodes. La première méthode est simple : '''vider les caches''' en changeant de programme. Leur contenu est rapatrié en mémoire RAM, puis les caches sont remis à zéro. Le vidage du cache recopie les lignes de cache ''dirty'' (modifiées) en RAM, puis efface/invalide tout le cache. C'est à cela que servent les instructions ''clean'' et d'invalidation vues plus haut, elles ont été inventées pour cette situation précise. Lorsque le système d'exploitation déclenche une commutation de contexte, à savoir qu'il change le programme en cours d'exécution, le processeur vide tous les caches du processeur. Les interruptions font la même chose, elles vide tous les caches du processeur. Une seconde méthode numérote chaque programme en cours d'exécution, chaque processus. Le numéro attribué est spécifique à chaque processus, ce qui fait qu'il est appelé un '''identifiant de processus CPU'''. Le processeur mémorise l'identifiant du programme en cours d'exécution dans un registre dédié. L'identifiant de processus CPU est utilisé lors des accès mémoire. Chaque ligne de cache contient le numéro de l'espace d'adressage associé, dans son ''tag''. Lors de chaque accès mémoire, l'ID du registre est comparé à l'ID de la ligne de cache accédée, pour vérifier que l'accès mémoire accède à la bonne donnée. Cette méthode n'est pas très économe en termes de transistors. L'usage d'identifiant de processus CPU est clairement meilleure en termes de performance, les commutations de contexte sont plus rapides. Par contre, le budget en transistor est plus important. Un autre défaut de cette méthode est que l'identifiant de processus est généralement codé sur une dizaine de bits, alors que le système d'exploitation utilise des identifiants de processus beaucoup plus larges, de 32 à 64 bits sur les CPU 32/64 bits. L'OS doit gérer la correspondance entre identifiants de processus CPU et ceux de l'OS. Parfois, pour cette raison, les OS n'utilisent pas toujours ce système d'identifiant de processus CPU. ====Les adresses synonymes perturbent aussi la gestion du cache==== La gestion des adresses synonymes est aussi un gros problème sur les caches virtuellement tagués. Pour rappel, il s'agit du cas où des adresses logiques différentes pointent vers la même adresse physique. Typiquement, quand deux programmes se partagent un morceau de mémoire, ce morceau correspondra à des adresses synonymes dans les deux espaces d'adressage. Mais il arrive que l'on ait des adresses synonymes dans le même espace d'adressage, ce n'est pas si rare ! Autant les adresses synonymes ne posent aucun problème avec les caches physiquement tagués, ce n'est pas le cas avec les caches virtuellement adressés. Sur ces caches, deux adresses logiques synonymes vont tomber dans deux lignes de cache différentes. Corriger ce problème demande d'ajouter des circuits annexes pour détecter les adresses synonymes, qui sont vraiment complexes et ont un cout en termes de performance. Aussi, les caches virtuellement tagués sont très peu utilisés sur les processeurs modernes. ===Les caches virtuellement adressés, mais physiquement tagués=== Si les caches physiquement et virtuellement tagués ont des défauts, il existe un intermédiaire qui est un bon compromis entre ces deux extrêmes. Il s'agit des '''caches virtuellement adressés - physiquement tagués''', aussi appelés '''caches pseudo-virtuels'''. Pour comprendre comment ils fonctionnent, précisons que ces caches sont soit des caches ''direct-mapped'', soit des caches associatifs par voie (composés de plusieurs RAM ''direct-mapped'' accédées en parallèle, plusieurs voies). L'accès à ce genre de cache se fait en deux temps : on accède à un ou plusieurs RAM ''direct-mapped'' et on vérifie ensuite les ''Tags'' pour sélectionner la bonne voie. Sur les caches ''direct-mapped'', on n'a qu'une seule RAM ''direct-mapped''. Sur les caches associatifs, on a plusieurs RAM ''direct-mapped'', appelées des voies, qui sont accédées en parallèle. L'accès se fait donc en deux étapes : adresser les RAM ''direct-mapped'' avec un indice, vérifier les ''tags'' avec le reste de l'adresse. Une autre chose à rappeler est que l'adresse logique est composée de deux parties : un numéro de page logique qui indique dans quel page se situe l'adresse, un décalage/''offset'' qui indique la position de l'adresse dans la page. La traduction d'adresse transforme le numéro de page logique en numéro de page physique, mais laisse le décalage intouché. L'idée est d'utiliser le décalage pour adresser les RAM avec le décalage, tandis que le numéro de page sert de ''tag''. Le décalage est découpé en deux lors de l'accès au cache : les bits de poids fort forment l'indice (l'adresse envoyée à la voie), les bits de poids faible donnent la position de l'adresse dans la ligne de cache. L'idée est d'utiliser un numéro de page physique pour les ''tags'', mais d'adresser les voies avec le décalage logique. Les deux servent à des instants différents : vérification des ''tags'' pour l'adresse physique, accès aux voies pour l'adresse logique. Ainsi, le problème des adresses synonymes ou homonymes est résolu par l'utilisation de l'adresse physique pour les tags. Par contre, l'accès au cache est plus rapide, car on utilise l'adresse logique pour la première étape. Le processeur accède à la TLB et récupère l'adresse physique pendant que l'on adresse les voies, les deux sont faits en parallèle, ce qui fait que tout se passe comme si l'accès à la TLB était gratuit. La TLB étant assez rapide comparé au cache, l'adresse physique est disponible quand on doit faire la comparaison avec les ''tags''. [[File:Virtual - Physical - Pseudo Virtual addressing.svg|centre|vignette|upright=2|Adressage pseudo virtuel des caches.]] Il s'agit d'un excellent compromis entre performance et correction des problèmes des adresses synonymes/homonymes. Tous les caches des processeurs haute performance utilisent cette méthode, au moins pour leurs caches L1. Les caches L2 tendent à utiliser des caches physiquement adressés, pour lesquels la latence d'accès est suffisante pour qu'on accède à la TLB en amont. La raison est assez simple à expliquer, elle provient d'une contrainte assez précise sur le calcul de l'indice. La conséquence est qu'un cache ''direct-mapped'' ne peut pas dépasser la taille d'une page, soit 4 kibioctets sur les ordinateurs actuels. Sur les caches associatifs, on peut dépasser cette limite en augmentant le nombre de voies, mais la taille maximale d'une voie reste celle d'une page. Cette contrainte n'est pas trop grave sur les caches de petite taille, dont les caches L1. La plupart d'entre eux ont trouvé un compromis idéal avec moins d'une dizaine de voies par cache, chacun de 4 kibioctets, ce qui donne des caches allant de 16 à 64 kibioctets, soit entre 4 et 16 voies. Par contre, un cache de grande taille doit utiliser un grand nombre de voies, ce qui est peu pratique. Aussi, cette technique de caches pseudo-virtuels n'est pas toujours appliquée sur les caches L2, qui sont physiquement adressés. Il faut dire qu'on accède au cache L2 lors d'un défaut dans le cache L1, et l'adresse physique est disponible à ce moment-là, elle a déjà été récupérée lors de l'accès au cache L1. On peut donc l'utiliser pour adresser le cache L2 sans perte de performance. ==Le remplacement des lignes de cache== Lorsqu'un cache est rempli et qu'on charge une nouvelle donnée dedans, il faut faire de la place pour cette dernière. Dans le cas d'un cache directement adressé, il n'y a rien à faire vu que la ligne de cache à évincer est déterminée lors de la conception du cache. Mais pour les autres caches, la donnée peut aller dans n'importe quelle ligne ou voie. Or, le choix des données à rapatrier en RAM doit être le plus judicieux possible : on doit virer de préférence des données inutiles. Rapatrier une donnée qui sera surement utilisée sous peu est inutile, et il vaudrait mieux supprimer des données qui ne serviront plus ou alors dans longtemps. Il existe différents algorithmes spécialement dédiés à résoudre ce problème efficacement, directement câblés dans les unités de gestion du cache. Certains sont vraiment très complexes, aussi je vais vous présenter quelques algorithmes particulièrement simples. Mais avant de voir ces algorithmes, il faut absolument que je vous parle d'une chose très importante. Quel que soit l'algorithme en question, il choisit la ligne de cache à évincer et recopie son contenu dans la RAM. Ce qui demande d'identifier et de sélectionner une ligne de cache parmi toutes les autres. Pour cela, le circuit de remplacement attribue une adresse chaque ligne de cache ! Vous avez bien vu : chaque ligne de cache est numérotée par une adresse, interne au cache. ===Le remplacement aléatoire=== Premier algorithme : la donnée effacée du cache est choisie au hasard ! C'est contre-intuitif, mais cet algorithme donne des résultats assez honorables, en plus d'utiliser très peu de portes logiques (un générateur de nombres pseudo-aléatoire est un circuit assez simple). Généralement, les défauts de cache sont séparés par un nombre assez important et irrégulier de cycles d'horloge. Dans ces conditions, cette technique donne un bon résultat. ===FIFO : first in, first out=== Avec l'algorithme FIFO, la donnée effacée du cache est la plus ancienne, celle chargée dans le cache avant les autres. Cet algorithme est très simple à implémenter en circuit, concevoir une mémoire de type FIFO n'étant pas très compliqué, comme on l’a vu dans le chapitre dédié à ce type de mémoires. Et on peut dire que dans le cas d'un cache, l'implémentation est encore plus simple et se contente d'un seul registre/compteur. Typiquement, il suffit d'ajouter un registre qui mémorise où se situe la donnée la plus récente. Toute insertion d'une nouvelle donnée se fait à l'adresse suivante, ce qui demande juste d'incrémenter le registre avant d'utiliser son contenu pour l'accès mémoire. [[File:Algorithme FIFO de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme FIFO de remplacement des lignes de cache.]] Cet algorithme possède une petite particularité sur les caches associatifs par voie : en augmentant le nombre d'ensembles, les performances peuvent se dégrader : c'est ce qu'on appelle l''''anomalie de Bélády'''. ===MRU : most recently used=== Avec l'algorithme MRU, la donnée remplacée est celle qui a été utilisée le plus récemment. Cet algorithme s'implémente simplement avec un registre, dans lequel on place le numéro de la dernière ligne de cache utilisée. Cet algorithme de remplacement est très utile quand un programme traverse des tableaux du premier élément jusqu'au dernier : les données du tableau sont rarement réutilisées, rendant le cache inutile. Il est prouvé que dans ces conditions, l'algorithme MRU est optimal. Mais dans toutes les autres conditions, cet algorithme a des performances assez misérables. ===LFU : least frequently used=== Avec l'algorithme LFU, la donnée supprimée est celle qui est utilisée le moins fréquemment. Cet algorithme s'implémente en associant un compteur à chaque ligne de cache, qui est incrémenté à chaque accès mémoire. La ligne la moins récemment utilisée est celle dont le compteur associé a la plus petite valeur. Implémenter cet algorithme prend pas mal de transistors, car il faut rajouter autant de compteurs qu'il y a de lignes de cache, en plus d'un circuit pour comparer les compteurs et d'un encodeur. [[File:Algorithme LFU de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme LFU de remplacement des lignes de cache]] ===LRU : least recently used=== Avec l'algorithme LRU, la donnée remplacée est celle qui a été utilisée le moins récemment. Cet algorithme se base sur le principe de localité temporelle, qui stipule qu'une donnée accédée récemment a de fortes chances d'être réutilisée dans un futur proche. Et inversement, la donnée la moins récemment utilisée du cache est celle qui a le plus de chance de ne servir à rien dans le futur. Autant la supprimer en priorité pour faire de la place à des données potentiellement utiles. Implémenter l'algorithme LRU peut se faire de différentes manières, qui ont pour point commun d'enregistrer les accès au cache pour en déduire la ligne la moins récemment accédée. La manière la plus simple demande d'utiliser un compteur pour chaque ligne de mémoire cache, un peu comme le LFU. La différence avec le LFU est que le compteur n'est pas incrémenté lors d'un accès mémoire. À la place, ce compteur est incrémenté régulièrement, chaque incrémentation ayant lieu en même temps pour tous les compteurs. Quand un bloc est chargé dans le cache, ce compteur est mis à zéro. Quand une ligne de cache doit être remplacée, un circuit va vérifier la valeur de tous les compteurs : la ligne LRU (la moins récemment utilisée), est celle dont le compteur a la valeur la plus haute. Le circuit est composé d'un paquet de comparateurs, et d'un encodeur, comme pour l'agorithme LFU. ===Les approximations du LRU=== Implémenter le LRU demande un nombre de transistors proportionnel au carré du nombre de lignes de cache. Autant dire que le LRU devient impraticable sur de gros caches. Ce qui fait que les processeurs modernes implémentent des variantes du LRU, moins couteuses en transistors, qui donnent un résultat approximativement semblable au LRU. En clair, ils ne sélectionnent pas toujours la ligne de cache la moins récemment utilisée, mais une ligne de cache parmi les moins récemment utilisées. Ce n'est pas un problème si grave que cela car les lignes les moins récemment utilisées ont toutes assez peu de chance d'être utilisées dans le futur. Entre choisir de remplacer une ligne qui a 0,5 % de chances d'être utilisée dans le futur et une autre qui a une chance de seulement 1 %, la différence est négligeable en termes de taux de succès. Mais les gains en termes de circuits ou de temps d'accès au cache de ces algorithmes sont très intéressants. L'algorithme le plus simple consiste à couper le cache (ou chaque voie s'il est associatif) en plusieurs sections. L'algorithme détermine la section la moins récemment utilisée, avant de choisir aléatoirement une ligne de cache dans cette section. Pour implémenter cet algorithme, il nous suffit d'un registre qui mémorise le morceau le moins récemment utilisé, et d'un circuit qui choisit aléatoirement une ligne de cache. Cette technique s'adapte particulièrement bien avec des caches associatifs à voies : il suffit d'utiliser autant de morceaux que de voies. Autre algorithme, un peu plus efficace : le '''pseudo-LRU de type M'''. Cet algorithme attribue un bit à chaque ligne de cache, bit qui sert à indiquer de façon approximative si la ligne de cache associée est une candidate pour un remplacement ou non. Il vaut 1 si la ligne n'est pas une candidate pour un remplacement et zéro sinon. Le bit est mis à 1 lorsque la ligne de cache associée est lue ou écrite. Évidemment, au fil du temps, toutes les lignes du cache finiront par avoir leur bit à 1. Lorsque cela arrive, l'algorithme remet tous les bits à zéro, sauf pour la dernière ligne de cache accédée. L'idée derrière cet algorithme est d'encercler la ligne de cache la moins récemment utilisée au fur et à mesure des accès. L'encerclement commence lorsque l'on remet tous les bits associés aux lignes de cache à 0, sauf pour la ligne accédée en dernier. Au fur et à mesure des accès, l'étau se resserre autour de la ligne de cache la moins récemment utilisée. Après un nombre suffisant d'accès, l'algorithme donne une estimation particulièrement fiable. Et comme les remplacements de lignes de cache sont rares comparés aux accès aux lignes, cet algorithme finit par donner une bonne estimation avant qu'on ait besoin d'effectuer un remplacement. Le dernier algorithme d'approximation, le '''PLURt''', se base sur ce qu'on appelle un arbre de décision. Il a besoin de n − 1 bits pour déterminer la ligne LRU. Ces bits doivent être organisés en arbre, comme illustré plus bas. Chacun de ces bits sert à dire : le LRU est à ma droite ou à ma gauche : il est à gauche si je vaux 0, et à droite si je vaux 1. Trouver le LRU se fait en traversant cet arbre, et en interprétant les bits un par un. Au fur et à mesure des lectures, les bits sont mis à jour dans cet arbre, et pointent plus ou moins bien sur le LRU. La mise à jour des bits s'effectue lors des lectures et écritures : quand une ligne est lue ou écrite, elle n'est pas la ligne LRU. Pour l'indiquer, les bits à 1 qui pointent vers la ligne de cache sont mis à 0 lors de la lecture ou écriture. {| |[[File:Organisation des bits avec l'algorithme PLURt.jpg|vignette|Organisation des bits avec l'algorithme PLURt.]] |[[File:Ligne de cache pointée par les bits de l'algorithme.png|vignette|Ligne de cache pointée par les bits de l'algorithme.]] |} ===LRU amélioré=== L'algorithme LRU, ainsi que ses variantes approximatives, sont très efficaces tant que le programme respecte relativement bien la localité temporelle. Par contre, Le LRU se comporte assez mal dans les circonstances ou la localité temporelle est mauvaise mais où la localité spatiale est respectée, le cas le plus emblématique étant le parcours d'un tableau. Pour résoudre ce problème, des variantes du LRU existent. Une variante très connue, l''''algorithme 2Q''', utilise deux caches : un cache FIFO pour les données accédées une seule fois et un second cache LRU. Évidemment, les données lues une seconde fois sont migrées du cache FIFO vers le cache LRU, ce qui n'est pas très pratique. Les processeurs n'utilisent donc pas cette technique, mais celle-ci est utilisée dans les caches de disque dur. D'autres variantes du LRU combinent plusieurs algorithmes à la fois et vont choisir lequel de ces algorithmes est le plus adapté à la situation. Notre cache pourra ainsi détecter s’il vaut mieux utiliser du MRU, du LRU, ou du LFU suivant la situation. ==Les écritures dans le cache : gestion et optimisations== Les écritures se font à une adresse mémoire bien précise, qui peut ou non être chargée dans le cache. Si la donnée à écrire est chargée dans le cache, elle est modifiée directement dans le cache, mais elle ne l'est pas forcément en mémoire RAM. Suivant le processeur, les écritures sont ou non propagées en mémoire RAM. Il existe deux stratégies d'écritures, appelées respectivement le ''write-back'' et le ''write-through''. Avec un cache ''write-back'', si la donnée à mettre à jour est présente dans le cache, on écrit dans celui-ci sans écrire dans la mémoire RAM. Dans ces conditions, une donnée n'est enregistrée en mémoire que si celle-ci quitte le cache, ce qui évite de nombreuses écritures mémoires inutiles. [[File:Cache write-through.png|centre|vignette|upright=2|Cache write-through.]] Avec les caches '''Write-Through''', toute écriture dans le cache est propagée en RAM. Cette stratégie augmente le nombre d'écritures dans la mémoire RAM, ce qui peut saturer le bus reliant le processeur à la mémoire. Les performances de ces caches sont donc légèrement moins bonnes que pour les caches ''write back''. Par contre, ils sont utiles dans les architectures avec plusieurs processeurs, comme nous le verrons dans les chapitres sur les architectures multiprocesseurs. [[File:Cache write-back.png|centre|vignette|upright=2|Cache write-back.]] ===Les caches ''Write-through''=== Sans optimisation particulière, on ne peut écrire dans un cache ''write-through'' pendant qu'une écriture en RAM a lieu en même temps : cela forcerait à effectuer deux écritures simultanées, en comptant celle imposée par l'écriture dans le cache. Pour éviter cela, certains caches ''write-through'' intègrent un '''tampon d’écriture''', qui sert de file d'attente pour les écritures en RAM. C'est une mémoire FIFO dans laquelle on place temporairement les données à écrire en RAM, où elles attendent en attendant que la RAM soit libre. Grâce à lui, le processeur peut écrire dans un cache même si d'autres écritures sont en attente dans le tampon d'écriture. Par souci d'efficacité, des écritures à la même adresse en attente dans le tampon d’écriture sont fusionnées en une seule. Cela fait un peu de place dans le tampon d’écriture, et lui permet d'accumuler plus d'écritures avant de devoir bloquer le cache. Il est aussi possible de fusionner des écritures à adresses consécutives de la mémoire en une seule écriture en rafales. Dans les deux cas, on parle de '''combinaison d'écriture'''. Mais la technique du tampon d'écriture a cependant un léger défaut qui se manifeste dans une situation bien précise : quand le processeur veut lire une donnée en attente dans le tampon d’écriture. La première manière de gérer cette situation est de mettre en attente la lecture tant que la donnée n'a pas été écrite en mémoire RAM. On peut aussi lire la donnée directement dans le tampon d'écriture, cette optimisation portant le nom de '''''store-to-load forwading'''''. Dans tous les cas, il faut détecter le cas où une lecture accède à une donnée dans le tampon d'écriture. À chaque lecture, l'adresse à lire est envoyée au tampon d'écriture, qui vérifie si une écriture en attente se fait à cette adresse. Pour cela, le tampon d’écriture doit être un cache, dont chaque entrée mémorise une écriture. Chaque ligne de cache contient la donnée à écrire, et le tag de la ligne de cache contient l'adresse où écrire la donnée. Notons que cache d'écriture a une politique de remplacement de type FIFO, le tampon d'écriture non-optimisé étant une mémoire FIFO. ===Les caches ''Write-back''=== Les caches ''write-back'' ont beau avoir des performances supérieures à celles des caches ''write-through'', il existe des optimisations qui permettent d'améliorer leurs performances. Ces optimisations consistent à ajouter des caches spécialisés à côté du cache proprement dit. Ces caches permettent de mémoriser des données qui sont éliminées du cache par les algorithmes de remplacement de ligne cache, sans pour autant faire une écriture en RAM. En suivant la procédure habituelle de remplacement des lignes de cache, on doit rapatrier la ligne en RAM avant d'en charger une nouvelle. On peut améliorer la situation en faisant l'inverse : on charge la nouvelle ligne pendant que l'ancienne donnée est rapatriée en RAM. Ainsi, la nouvelle ligne est disponible plus tôt pour le processeur, diminuant son temps d'attente. Pour implémenter cette technique, on doit mémoriser l'ancienne ligne de cache temporairement dans un '''cache d’éviction''' (ou ''write-back buffer''). [[File:Cache d’éviction.png|centre|vignette|upright=2|Cache d’éviction]] Les caches directement adressés ou associatifs par voie possèdent aussi un tampon d’écriture amélioré. Pour limiter les défauts par conflit de ces caches, des scientifiques ont eu l'idée d'insérer un cache pour stocker les données virées du cache. En faisant ainsi, si une donnée est virée du cache, on peut alors la retrouver dans ce cache spécialisé. Ce cache s'appelle le '''cache de victime'''. Ce cache de victime est géré par un algorithme de suppression des lignes de cache de type FIFO. Petit détail : ce cache utilise un tag légèrement plus long que celui du cache directement adressé au-dessus de lui. L'index de la ligne de cache doit en effet être contenu dans le tag du cache de victime, pour bien distinguer deux adresses différentes, qui iraient dans la même ligne du cache juste au-dessus. [[File:Victim Cache Implementation Example.svg|centre|vignette|upright=1|Cache de victime.]] ===La configuration du fonctionnement du cache=== Sur de nombreux processeurs, il est possible de configurer la mémoire cache pour qu'elle fonctionne soit en mode ''write-back'', soit en mode ''write-through''. Pour cela, les processeurs modernes incorporent des '''registres de configuration du cache'''. Le terme ''registre de configuration du cache'' est assez transparent et indique bien quel est leur rôle. Ils configurent comment le cache est utilisé et permettent notamment de configurer le cache pour dire s'il doit fonctionner en mode ''write-back'' ou ''write-through''. Ils permettent aussi d'activer ou de désactiver la combinaison sur écriture. Les registres en question sont configurés soit par le BIOS, soit par le système d'exploitation. Ce sont des registres protégés, que les applications ne peuvent pas configurer, elles n'en ont pas le droit. Typiquement, ils ne sont accessibles en écriture qu'en mode noyau. Sur les processeurs x86, les registres de configuration du cache sont appelés des '''''Memory type range registers''''' (''MTRRs''). Les MTRRs sont assez nombreux, et il y a notamment une différence entre mode réel et protégé. Si vous vous souvenez des chapitres sur le mode d'adressage et la mémoire virtuelle, vous vous souvenez que les processeurs x86 incorporent plusieurs modes de fonctionnement. En mode réel, le processeur ne peut adresser qu'un mébioctet de RAM, avec un système de segmentation particulier. En mode protégé, le processeur peut adresser toute la mémoire et la segmentation fonctionne différemment, quand elle n'est pas simplement désactivée. Les MTRRs sont séparés en deux : ceux pour le mode réel, ceux pour le mode protégé. Les MTRRs fixes sont ceux qui configurent le cache en mode réel, ils étaient utilisés pour gérer l'accès au BIOS, à la mémoire VGA de la carte graphique, et quelques autres accès aux entrées-sorties basiques gérées nativement par le BIOS. Pour le mode protégé, les processeurs au-delà du 386 incorporent des MTRRs variables, qui servent pour les autres entrées-sorties en général, notamment les périphériques PCI, la mémoire vidéo de la carte graphique, et j'en passe. De nos jours, les registres de configuration du cache sont désuets et cette fonctionnalité est gérée directement par la mémoire virtuelle. La table des pages contient, pour chaque page mémoire, des bits de contrôle qui disent si la page mémoire est cacheable ou non. Le contournement de cache est alors géré par le système de mémoire virtuelle, le cache de TLB et tout ce qui va avec. ===L’allocation sur écriture=== Que faire quand une écriture modifie une donnée qui n'est pas dans le cache ? Doit-on écrire la donnée dans le cache, ou non ? Si la donnée est écrite dans le cache, on dit que le cache fait une '''allocation sur l'écriture''' (ou ''write-allocate''). Certains caches effectuent une telle allocation sur écriture, mais d'autres ne le font pas ou du moins pas systématiquement. L’allocation sur écriture peut se décliner en deux sous-catégories : le '''chargement à la demande''' et l''''écriture immédiate'''. Dans le premier cas, on charge la donnée à modifier dans le cache, et on la remplace avec la donnée écrite. Dans l'écriture immédiate, l'écriture a lieu directement dans le cache et la donnée à modifier n'est pas chargée dans le cache. Évidemment, seule une portion de la ligne de cache contient la donnée écrite (valide), et le reste contient des données invalides. Le cache doit savoir quelles sont les portions du cache qui sont valides : cela demande d'utiliser un ''sector cache''. [[File:Write-back with write-allocation.svg|centre|vignette|upright=2|Cache Write-back avec allocation sur écriture.]] Sans allocation sur écriture, l'écriture est transférée directement aux niveaux de cache inférieurs ou à la mémoire si la donnée à modifier n'est pas dans le cache. Certains caches de ce genre utilisent une petite optimisation : lors de toute écriture, ils supposent que l'écriture donnera un succès de cache. Si c'est le cas, la ligne de cache qui contient la donnée est mise à jour avec la donnée à écrire. Mais si ce n'est pas le cas, la ligne de cache est invalidée, et l'écriture est transférée directement à la mémoire ou aux niveaux de cache inférieurs. [[File:Write-through with no-write-allocation.svg|centre|vignette|upright=2|Cache Write-through sans allocation sur écriture.]] ===La cohérence des caches=== Il arrive parfois que la mémoire d'un ordinateur soit mise à jour, sans que les modifications soient répercutées dans les mémoires cache. Dans ce cas, le cache contient une donnée périmée. Or, un processeur doit toujours éviter de se retrouver avec une donnée périmée et doit toujours avoir la valeur correcte dans ses caches : cela s'appelle la '''cohérence des caches'''. Il est possible de se retrouver avec des valeurs périmées dans le cache sur les ordinateurs avec plusieurs processeurs, ou si un périphérique écrit en RAM, les modifications ne sont pas répercutées automatiquement dans les mémoires cache. Pour résoudre ce problème, on peut interdire de charger dans le cache des données stockées dans les zones de la mémoire dédiées aux périphériques. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires cache. Autre solution : utiliser le fait que les périphériques déclenchent une interruption matérielle pour laisser le contrôleur DMA accéder à la mémoire. Dans ce cas, il suffit de vider les caches à chaque interruption matérielle. Le processeur peut le faire automatiquement, ou fournir des instructions pour. ==Le ''cache bypassing'' : contourner le cache== Dans certaines situations, le cache n'est pas utilisé pour certains accès mémoire. Diverses techniques permettent en effet d'effectuer des accès mémoire qui contournent le cache, qui ne passent pas par le cache. Ils sont utilisés quand l'accès en cache fait que des instructions normales ne fonctionnent pas. Par exemple, de tels accès directs à la RAM sont notamment utilisés pour l'implémentation d'instructions atomiques, une classe d'instructions spécifiques utilisées sur les processeurs multicœurs, dont nous parlerons dans plusieurs chapitres. Mais ils sont aussi utilisés pour l'accès aux périphériques, ce que nous allons voir maintenant. ===Accéder aux périphériques demande de contourner le cache=== Pour rappel, un périphérique (au sens d'entrée-sortie) contient des registres d’interfaçage qui ont une adresse au même titre que les cases mémoire. Un périphérique peut à tout instant modifier ses registres d’interfaçage, ce qui se répercute automatiquement dans l'espace d'adressage, mais rien de tout cela n'est transmis au cache. Si les accès aux périphériques passaient par l'intermédiaire du cache, on aurait droit à des problèmes. On aurait encore une fois droit à des problèmes de cohérence des caches. Le problème est géré différemment suivant que l'on utilise un espace d'adressage séparé ou des entrées-sorties mappées en mémoire. La solution est que les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache. Cela demande d'adapter le cache et le processeur. L'implémentation exacte dépend de comment sont adressés les périphériques. Pour rappel, il y a deux solutions pour adresser les périphériques : soit les périphériques disposent d'un espace d'adressage séparé de celui de la mémoire, soit il y un espace d'adressage unique partagé entre processeur et mémoire. Les deux cas donnent des solutions différentes. Avec un espace d'adressage séparé, l'espace d'adressage des périphériques n'est pas caché : aucun accès dans cet espace d'adressage ne passe par le cache. La mémoire cache n'est utilisée que pour l'espace d'adressage des mémoires, rien d'autre. C'est de loin le cas le plus simple : il suffit de concevoir le processeur pour. Il dispose d'instructions séparées pour les accès aux registres d’interfaçage et à la RAM/ROM, les premières ne passent pas par le cache, les autres si. Avec des entrées-sorties mappées en mémoire, la même solution est utilisée, mais dans une version un peu différente. Là encore, les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache, si on veut qu'ils marchent comme ils le doivent. Cela demande d'adapter le cache et le matériel pour que accès aux périphériques mappés en mémoire contournent le cache. Des adresses, voire des zones entières de la mémoire, sont marquées comme étant non-cachables. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires caches. Là encore, le processeur doit être prévu pour : on doit pouvoir le configurer de manière à marquer certaines zones de la RAM comme non-cacheable. Reste qu'il faut marquer des régions de la RAM comme non-cacheable. Pour cela, on améliore les registres de configuration du cache, vus plus haut, afin qu'ils permettent de configurer certaines portions de la RAM pour préciser qu'elles ne doivent pas être mises en cache, qu'il faut activer le contournement de cache pour celles-ci. ===Contourner le cache pour des raisons de performance=== Il arrive que des données avec une faible localité soient chargées dans le cache inutilement. Or, il vaut mieux que ces données transitent directement entre le processeur et la mémoire, sans passer par l'intermédiaire du cache. Pour cela, le processeur peut fournir des instructions d'accès mémoire qui ne passent pas par le cache, à côté d'instructions normales. De telle instructions sont appelées des '''instructions mémoire non-temporelles'''. Non-temporelle, dans le sens : pas de localité temporelle (c.a.d que les données ne seront pas réutilisées plus tard). Mais il existe aussi des techniques matérielles, où le cache détecte à l'exécution les lectures qui gagnent à contourner le cache. La dernière méthode demande d'identifier les instructions à l'origine des défauts de cache, le processeur accédant directement à la RAM quand une telle instruction est détectée. Si une instruction d'accès mémoire fait trop de défauts de cache, c'est signe qu'elle gagne à contourner le cache. L'idée est de mémoriser, pour chaque instruction d'accès mémoire, un historique de ses défauts de cache. Il existe plusieurs méthodes pour cela, mais toutes demandent d'ajouter de quoi mémoriser l'historique des défauts de cache des instructions. L'historique est mémorisé dans une mémoire appelée la '''table d’historique des défauts de lecture''' (''load miss history table''), qui est souvent un cache. L'historique en question est, dans sa version la plus simple, un compteur de quelques bits incrémenté à chaque succès de cache et décrémenté à chaque défaut de cache, qui indique si l'instruction a en moyenne fait plus de défauts ou de succès de cache. La table associe le ''program counter'' d'une instruction mémoire à cet historique. À la première exécution d'une instruction d'accès mémoire, une entrée de cette table est réservée pour l'instruction. Lors des accès ultérieurs, le processeur récupérer les informations associées et décide s'il faut contourner le cache ou non. ==La hiérarchie mémoire des caches== [[File:Cache Hierarchy.png|vignette|Hiérarchie de caches]] On pourrait croire qu'un seul cache est largement suffisant pour compenser la lenteur de la mémoire. Hélas, les processeurs sont devenus tellement rapides que les caches sont eux-mêmes très lents ! Pour rappel, plus une mémoire peut contenir de données, plus elle est lente. Et les caches ne sont pas épargnés. Si on devait utiliser un seul cache, celui-ci serait très gros et donc trop lent. La situation qu'on cherche à éviter avec la mémoire RAM revient de plus belle. Même problème, même solution : si on a décidé de diviser la mémoire principale en plusieurs mémoires de taille et de vitesse différentes, on peut bien faire la même chose avec la mémoire cache. Depuis environ une vingtaine d'années, un processeur contient plusieurs caches de capacités très différentes : les caches L1, L2 et parfois un cache L3. Certains de ces caches sont petits, mais très rapides : c'est ceux auxquels on va accéder en priorité. Viennent ensuite d'autres caches, de taille variable, mais plus lents. Les processeurs ont donc une hiérarchie de caches qui se fait de plus en plus complexe avec le temps. Cette hiérarchie est composée de plusieurs niveaux de cache, qui vont des niveaux inférieurs proches de la mémoire RAM à des niveaux supérieurs proches du processeur. Plus on monte vers les niveaux supérieurs, plus les caches sont petits et rapides. Un accès mémoire dans une hiérarchie de cache fonctionne comme suit : on commence par vérifier si la donnée recherchée est dans le cache le plus rapide, à savoir le cache L1. Si c'est le cas,n on la charge depuis ce cache directement. Si elle n’y est pas, on vérifie si elle est dans le cache de niveau supérieur, le cache L2. Et rebelote ! Si elle n'y est pas, on vérifie le cache du niveau supérieur. Et on répète cette opération, jusqu’à avoir vérifié tous les caches. Si la donnée n'est dans aucun cache, on doit alors aller chercher la donnée en mémoire. [[File:Hiérarchie de caches.png|centre|vignette|upright=2|Hiérarchie de caches]] Il y a des différences assez notables entre chaque niveau de cache. Par exemple, les différents niveaux de cache n'ont pas forcément les mêmes politiques de remplacement des lignes de cache. Le cache L1 a généralement une politique de remplacement simple, très rapide, mais peu efficace. De même, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Le cache le plus proche de la mémoire est appelé le '''cache de dernier niveau''', ''Last Level Cache'' en anglais. Il a parfois des caractéristiques totalement différentes des autres caches. Par exemple, sur les processeurs multicoeurs, le cache L3 n'est techniquement pas dans le cœur, mais fait partie d'un ensemble de circuits reliés, comme le contrôleur mémoire ou l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc. ===Les caches exclusifs et inclusifs=== Notons que du point de vue de cette vérification, il faut distinguer les caches inclusifs et exclusifs. Avec les caches inclusifs, si une donnée est présente dans un cache, alors elle est présente dans les caches des niveaux inférieurs, ce qui implique l'existence de données en doublon dans plusieurs niveaux de cache. À l'opposé, les caches exclusifs font que toute donnée est présente dans un seul cache, pas les autres. Il existe aussi des caches qui ne sont ni inclusifs, ni exclusifs. Sur ces caches, chaque niveau de cache gère lui-même ses données, sans se préoccuper du contenu des autres caches. Pas besoin de mettre à jour les niveaux de cache antérieurs en cas de mise à jour de son contenu, ou en cas d'éviction d'une ligne de cache. La conception de tels caches est bien plus simple. Dans les '''caches exclusifs''', le contenu d'un cache n'est pas recopié dans le cache de niveau inférieur. Il n'y a pas de donnée en double et on utilise 100 % de la capacité du cache, ce qui améliore le taux de succès. Par contre, le temps d'accès est un peu plus long. La raison est que si une donnée n'est pas dans le cache L1, on doit vérifier l'intégralité du cache L2, puis du cache L3. De plus, assurer qu'une donnée n'est présente que dans un seul cache nécessite aux différents niveaux de caches de communiquer entre eux pour garantir que l'on a pas de copies en trop d'une ligne de cache, ce qui peut prendre du temps. [[File:Caches exclusifs.png|centre|vignette|upright=2|Caches exclusifs]] Dans le cas des '''caches inclusifs''', le contenu d'un cache est recopié dans les caches de niveau inférieur. Par exemple, le cache L1 est recopié dans le cache L2 et éventuellement dans le cache L3. Ce genre de cache a un avantage : le temps d'accès à une donnée est plus faible. La raison est qu'il ne faut pas vérifier tout un cache, mais seulement la partie qui ne contient pas de donnée en doublon. Par exemple, si la donnée voulue n'est pas dans le cache L1, on n'est pas obligé de vérifier la partie du cache L2 qui contient la copie du L1. Ainsi, pas besoin de vérifier certaines portions du cache, ce qui est plus rapide et permet de simplifier les circuits de vérification. En contrepartie, l'inclusion fait que qu'une partie du cache contient des copies inutiles, comme si le cache était plus petit. De plus, maintenir l'inclusion est compliqué et demande des circuits en plus et/ou des échanges de données entre caches. [[File:Caches inclusifs.png|centre|vignette|upright=2|Caches inclusifs]] Maintenir l'inclusion demande de respecter des contraintes assez fortes, ce qui ne se fait pas facilement. Premièrement, toute donnée chargée dans un cache doit aussi l'être dans les caches de niveau inférieur. Ensuite, quand une donnée est présente dans un cache, elle doit être maintenue dans les niveaux de cache inférieurs. De plus, toute donnée effacée d'un cache doit être effacée des niveaux de cache supérieurs : si une donnée quitte le cache L2, elle doit être effacée du L1. Ces trois contraintes posent des problèmes si chaque cache décide du remplacement des lignes de cache en utilisant un algorithme comme LRU, LFU, MRU, ou autre, qui utilise l'historique des accès. En effet, dans ce cas, le cache décide de remplacer les lignes de cache selon l'historique des accès, historique qui varie suivant chaque niveau de cache. Par exemple, une donnée rarement utilisée dans le L2 peut parfaitement être très fréquemment utilisée dans le L1 : la donnée sera alors remplacée dans le L2, mais sera maintenue dans le L1. On observe aussi des problèmes quand il existe plusieurs caches à un seul niveau : chaque cache peut remplacer les lignes de cache d'une manière indépendante des autres caches du même niveau, donnant lieu au même type de problème. Pour maintenir l'inclusion, les caches doivent se transmettre des informations qui permettent de maintenir l'inclusion. Par exemple, les caches de niveaux inférieurs doivent prévenir les niveaux de cache supérieurs quand ils remplacent une ligne de cache. De plus, toute mise à jour dans un cache doit être répercutée dans les niveaux de cache inférieurs et/ou supérieurs. On doit donc transférer des informations de mise à jour entre les différents niveaux de cache. Généralement, le contenu des caches d'instruction n'est pas inclus dans les caches de niveau inférieurs, afin d'éviter que les instructions et les données se marchent sur les pieds. Enfin, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Dans ce cas, l'inclusion est plus difficile à maintenir, pour des raisons assez techniques. ===Les caches eDRAM, sur la carte mère et autres=== D'ordinaire, les mémoires caches sont intégrées au processeur, à savoir que cache et CPU sont dans le même circuit imprimé. Les caches sont donc fabriqués avec de la SRAM, seule forme de mémoire qu'on peut implémenter dans un circuit intégré. Intégrer tous les caches dans le processeur est une solution et efficace. Mais certains processeurs ont procédé autrement. [[File:Cache-on-a-stick module.jpg|vignette|Cache-on-a-stick module]] Des processeurs assez anciens incorporaient un cache L1 dans le processeur, mais plaçaient un cache L2 sur la carte mère. Le cache était clippé sur un connecteur sur la carte mère, un peu comme le sont les barrettes de mémoire. On parlait alors de '''''Cache on a stick''''' (COAST). On aurait pu s'attendre à ce que de tels caches soient en DRAM, vu qu'ils sont placés sur des barrettes de RAM, mais la ressemblance avec la mémoire RAM principale s'arrête là. Le cache était fabriqué en mémoire SRAM, même s'il est en théorie possible de faire de tels caches avec de la DRAM. Les premiers processeurs avec un cache faisaient ainsi, au début des années 90. Il a été introduiot sur les processeurs Motorola, et a été utilisé sur les IBM PC et les Macintosh de l'époque. Les ordinateurs Macintosh utilisaient de tels caches, pour la pluaprt des modèles. Pour ce qui est des PC, les premiers processeurs x86 faisaient pareil, notamment les processeurs Intel. Le 486, le Pentium et le Pentium 2 utilisaient des ''Cache on a stick''. L'avantage est que cela permettait de mettre plus de cache, à une époque où les circuits étaient limités en transistors. De plus, cela permettait au consommateur de choisir quelle quantité de cache il voulait, selon ses finances. Il était possible de laisser le processeur fonctionner soit sans mémoire cache, soit avec un cache de 256 Kibioctets, de 512 Kibioctets, etc. Il était possible d'upgrader le cache si besoin. Pour les CPU Intel, le cache était connecté sur le bus système, au même titre que la mémoire RAM et les entrées-sorties. Il faut dire que les processeurs de l'époque utilisaient un bus système et n'avaient pas de bus mémoire dédié. Mais en théorie, rien n’empêche de connecter le cache sur un bus mémoire dédié. Toujours est-il que les lectures et écritures étaient propagées à la fois dans le cache et la RAM. Les écritures se faisaient dans les deux, systématiquement dans la RAM, mais aussi dans le cache en cas de succès de cache. Les lectures étaient servies soit par le cache en cas de succès de cache, soit par la RAM en cas de défaut de cache. Si le cache répondait en premier, la transaction sur le bus se terminait précocement et l'accès en RAM était abandonné. [[File:Intel486 Иерархия памяти.png|centre|vignette|upright=2.5|Intel486 : le cache était connecté sur le bus système.]] À l'inverse, certains processeurs possédaient un cache fabriqué en mémoire DRAM, et plus précisément avec de la mémoire eDRAM. Le cache n'était pas intégré dans le même circuit imprimé que le processeur, mais profitait d'une architecture en ''chiplet''. Pour rappel, cela veut dire que le processeur est en réalité composé de plusieurs circuits intégré séparés, mais interconnectés et soudés sur un même PCB carré. Avec un cache en eDRAM, le cache avait son propre circuit intégré, séparé du circuit intégré du processeur ou du circuit intégré pour le contrôleur mémoire/IO. Un exemple est celui du cache des processeurs Intel de microarchitecture Broadwell, vus dans ce chapitre dans la section sur les caches splittés. Les tags étaient intégrés dans le circuit intégré du processeur, mais les données étaient mémorisées dans une puce d'eDRAM séparée. La puce eDRAM correspondait en réalité à une DRAM adressable qui servait de DRAM pour les données et mémorisaient les voies du cache. ==Les caches adressés par somme et hashés== Les caches adressés par somme sont optimisés pour incorporer certains calculs d'adresse directement dans le cache lui-même. Pour rappel, certains modes d'adressage impliquent un calcul d'adresse, qui ajoute une constante à une adresse de base. Généralement, l'adresse de base est l'adresse d'un tableau ou d'une structure, et la constante ajoutée indique la position de la donnée dans le tableau/la structure. Les caches hashés et les caches adressés par somme permettent de faire l'addition directement dans la mémoire cache. Voyons d'abord les caches hashés, avant de passer aux caches adressés par somme. Sur les '''caches hashés''', l'addition est remplacée par une autre opération, par exemple des opérations bit à bit du style XOR, AND ou OR, etc. Seulement, utiliser des opérations bit à bit pose un problème : il arrive que deux couples Adresse/décalage donnent le même résultat. Par exemple, le couple Adresse/décalage 11101111/0001 donnera la même adresse que le couple 11110000/0000. Dit autrement, deux adresses censées être différentes (après application du décalage) sont en réalité attribuées à la même ligne de cache. Il est toutefois possible de gérer ces situations, mais cela demande des astuces de haute volée pour faire fonctionner la mémoire cache correctement. Sur les '''caches adressés par somme''', le décodeur est modifié pour se passer de l'addition. Pour comprendre comment, il faut rappeler qu'un décodeur normal est composé de comparateurs, qui vérifient si l'entrée est égale à une constante bien précise. Sur un cache ordinaire, l'addition est faite séparément du décodage des adresses par le cache, dans l'unité de calcul ou dans l'unité de génération d'adresse. [[File:Non sum adressed cache.png|centre|vignette|upright=2|Cache normal.]] Mais les caches adressés par somme modifient le décodeur, qui est alors composé de comparateurs qui testent si la somme adresse + décalage est égale à une constante. [[File:Cache adressé par somme.png|centre|vignette|upright=2|Cache adressé par somme.]] Chaque circuit du décodeur fait le test suivant, avec K une constante qui dépend du circuit : : <math>A + B = K</math> Ce qui est équivalent à faire le test suivant : : <math>A + B - K = 0</math> En complément à deux, on a <math>- K = \overline{K} + 1</math>. En injectant dans l'équation précédente, on a : : <math>A + B + \overline{K} + 1 = 0</math> En réorganisant les termes, on a : : <math>A + B + \overline{K} = - 1</math> Il suffit d'utiliser un additionneur ''carry-save'' pour faire l'addition des trois termes. Rappelons qu'un tel additionneur fournit deux résultats en sortie : une somme calculée sans propager les retenues et les retenues en question. Notons que les retenues sont à décaler d'un cran, vu qu'elles sont censées s'appliquer à la colonne suivante. En notant la somme S et les retenues R, on a: : <math>S + (R << 1) = - 1 </math>, le décalage d'un cran à gauche étant noté <math><< 1</math>. Ensuite, -1 est codé avec un nombre dont tous les bits sont à 1 en complément à un/deux. : <math>S + (R << 1) = 111 \cdots 111111</math> [[File:Sum + retenue add.png|centre|vignette|upright=2|Sum + retenue add]] Un simple raisonnement nous permet de savoir si le résultat est bien -1, sans faire l'addition <math>S + (R << 1)</math>. En effet, on ne peut obtenir -1 que si la somme est l'inverse des retenues : un 0 dans le premier nombre correspond à un 1 dans l'autre, et réciproquement. En clair, on doit avoir <math>\overline{S} = R << 1</math>. Pour vérifier cela, il suffit de faire un simple XOR entre la somme et les retenues décalées d'un cran. On a alors : : <math>S \oplus (R << 1) = 111 \cdots 111111</math> La comparaison avec -1 se fait avec une porte ET à plusieurs entrées. En effet, la porte donnera un 1 seulement si tous les bits d'entrée sont à 1, ce qui est ce qu'on veut tester. Au final, l'additionneur pour l'addition adresse + décalage est remplacé par un additionneur carry-save suivi d'une couche de portes XOR et d'un comparateur avec une constante, ce qui économise de circuits et améliore les performances. [[File:Final circuit of sum addressed cache.png|centre|vignette|upright=2|Cache adressé par somme.]] En prenant en compte que la constante K est justement une constante, certaines entrées de l'additionneur carry-save sont toujours à 0 ou à 1, ce qui permet quelques simplifications à grand coup d’algèbre de Boole. Chaque additionneur complet qui compose l’additionneur carry-save est remplacée par des demi-additionneurs (ou par un circuit similaire). Autant dire que l'on gagne tout de même un petit peu en rapidité, en supprimant une couche de portes logiques. Le circuit de décodage économise aussi des portes logiques, ce qui est appréciable. ==Les caches à accès uniforme et non-uniforme== Intuitivement, le temps d'accès au cache est le même pour toutes les lignes de cache. Il s'agit de cache appelés '''caches à accès uniforme''', sous-entendu à temps d'accès uniforme. Mais sur les caches de grande capacité, il arrive souvent que le temps de propagation des signaux varie fortement suivant la ligne de cache à lire. D'ordinaire, on se cale sur la ligne de cache la plus lente pour caler la fréquence d'horloge du cache, même si on pourrait faire mieux. Cependant, les '''caches à accès non uniforme''' ont une latence différente pour chaque ligne d'un même cache. Certaines lignes de cache sont plus rapides que d'autres. Niveau terminologie, nous allons parler de caches UCA et NUCA : ''Uniform Access Cache'' pour les caches à accès uniforme, ''Non-Uniform Access Cache'' pour les caches à accès non-uniforme. [[File:Caches UCA et NUCA.png|vignette|Caches UCA et NUCA.]] Les caches NUCA et UCA sont souvent composés de plusieurs banques séparées, typiquement une par voie. Sur les caches UCA, les banques sont interconnectées avec le processeur de manière à ce que toutes les interconnexions ont la même longueur pour toutes les banques. Typiquement, les banques sont organisées en carré, avec les interconnexions qui partent du centre, avec une disposition en H, illustrée ci-contre Mais avec les caches NUCA, ce n'est pas le cas. Les interconnexions sont simplifiées et ont des longueurs différentes. Les caches NUCA n'ont pas tous le même genre d'interconnexions, qui dépendent du cache NUCA. En général, les interconnexion forme un réseau avec des sortes de routeurs qui redirigent les données/commandes vers la bonne destination : cache ou processeur. Les banques plus proches du processeur sont accessibles plus rapidement que celles éloignées, même si la différence n'est pas énorme. Les caches NUCA sont généralement associatifs par voie. Les plus simples utilisent une banque par voie pour le cache, ce qui fait que certaines voies répondent plus vite que les autres. La détection des succès de cache est alors plus rapide si la donnée lue/écrite est dans une voie/banque rapide. En théorie, les défauts de cache demandent de vérifier toutes les banques, et se calent donc sur la pire latence. Mais divers caches se débrouillent pour que ce ne soit pas le cas, soit en vérifiant les banquyes unes par une, soit par un mécanisme de recherche plus complexe. Les caches NUCA sont surtout utilisés pour les caches L3 et L4, éventuellement les caches L2. Les caches L1 sont systématiquement des caches UCA, car la latence de l'accès au cache L1 est utilisée par le processeur pour décider quand lancer les instructions. Pour simplifier, le processeur peut démarrer en avance une instruction avant qu'une opérande soit lue dans le cache L1, de manière à ce que la donnée arrive en entrée de l'ALU pile en même temps que l'instruction. Une histoire d'exécution dans le désordre et d'émission anticipée des instructions qu'on détaillera dans une bonne dizaine de chapitres. Toujours est-il que tout est plus simple pour le processeur si le cache L1 a un temps d'accès fixe. Par contre, les caches L3 et L4 sont traités en attendant que les données arrivent, le processeur reprend l'exécution des instructions quand les caches L3 et L4 ont terminé de répondre, pas avant. Avec l'association une banque = une voie, la correspondance ligne de cache → bloc de mémoire qui est statique : on ne peut pas déplacer le contenu d'une ligne de cache dans une autre portion de mémoire plus rapide suivant les besoins. Mais la recherche académique a étudié le cas où la correspondance entre une ligne de cache et une banque varie à l’exécution. Pour nommer cette distinction, on parle de caches S-NUCA (''Static NUCA'') et D-NUCA (''Dynamic NUCA''). Intuitivement, on s'attend à ce que les caches D-NUCA soient plus performants que les caches S-NUCA. Les lignes de cache les plus utilisées peuvent migrer dans une banque rapide, alors que les lignes de cache moins utilisées vont dans une banque éloignée. Les lignes de cache se répartissent dans le cache dynamiquement dans les banques où elles sont le plus adaptées. Mais paradoxalement, le gain des caches D-NUCA est presque nul, voire insignifiant. La raison est que les caches D-NUCA doivent incorporer un système pour déterminer dans quelle banque se situe la donnée pour détecter les succès/défauts de cache, ainsi qu'un système pour migrer les données entre banques. Et ce système augmente le temps d'accès au cache, réduisant à néant l'intérêt d'un cache D-NUCA. Si on économise quelques microsecondes de temps d'accès en passant d'un cache UCA à un cache S-NUCA, ce n'est pas pour les perdre en passant à un D-NUCA. La majorité des caches D-NUCA sont donc en cours de recherche, mais ne sont pas utilisés en pratique. ==La tolérance aux erreurs des caches== Une mémoire cache reste avant tout une mémoire RAM, bien que ce soit de la SRAM. Elle n'est pas parfaite et est donc sujette à des erreurs, qui peuvent inverser un bit ou l'effacer. De telles erreurs sont liées à des rayons cosmiques très énergétiques, à des particules alpha produites par le packaging ou le métal deu circuit intégré, peu importe : l'essentiel est qu'ils inversent parfois un bit. Les mémoires modernes savent se protéger contre de telles erreurs, en utilisant trois moyens. ===Les mémoires caches ECC et à bit de parité=== Le premier moyen est l'usage de codes correcteurs d'erreurs, qui ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Les bits ajoutés dépendent de la donnée mémorisée dans le byte, et servent à détecter une erreur, éventuellement à la corriger. Le cas le plus simple ajoute un simple bit de parité pour chaque byte et se contente de détecter les erreurs dans les corriger. Les autres codes ECC permettent eux de corriger des erreurs, mais ils demandent d'ajouter au moins deux bits par byte, ce qui a un cout en circuit plus élevé. Un simple bit de parité permet de détecter qu'un bit a été inversé, mais ne permet pas de corriger l'erreur. En soi, ce n'est pas un problème. Si une erreur est détectée, on considère que la ligne de cache est invalide. Le cache gère la situation comme un défaut de cache et va chercher la donnée valide en mémoire RAM. Le cout en circuits est donc faible, mais les défauts de cache sont plus nombreux. Les codes ECC sont eux capables de corriger les erreurs, si elles ne modifient pas trop de bits d'un coup. Par contre, ils utilisent deux à trois bits par octet, ce qui a un cout en circuits loin d'être négligeable. Il y a donc un compromis entre défauts de cache et cout en circuits. La gestion de l'ECC est différente suivant le niveau de cache. Généralement, le cache L1 n'utilise pas l'ECC mais se contente d'un simple bit de parité pour éviter la corruption de ses données. Le cache étant petit, les corruptions de données sont assez rares, et les défauts de cache induits faibles. Il est plus important d'utiliser un code de détection d'erreur simple, rapide, qui ne ralentit pas le cache et n'augmente pas sa latence. Si une ligne de cache est corrompue, il a juste à aller lire la ligne depuis le cache L2, ou un niveau de cache inférieur. Du moins, c'est possible sur le cache en question est un cache inclusif et/ou ''write-through''. Par contre, le niveau de cache L2 et ceux en-dessous utilisent presque systématiquement une mémoire SRAM ECC. La raison principale étant que ce sont des caches assez gros, pour lesquels la probabilité d'une erreur est assez élevée. Plus une mémoire a de bits et prend de la place, plus il y a une chance élevée qu'un bit s'inverse. Et vu que les caches L2/L3/L4 sont par nature plus lents et plus gros, ils peuvent se permettre le cout en performance lié à l'ECC, idem pour le cout en circuit. Sans compter qu'en cas d'erreur, ils doivent aller lire la ligne de cache originelle en mémoire RAM, ce qui est très lent ! Mieux vaut corriger l'erreur sur place en utilisant l'ECC. ===L'usage du ''memory scrubbing'' sur les caches=== La plupart des erreurs ne changent qu'un seul bit dans un byte, mais le problème est que ces erreurs s'accumulent. Entre deux accès à une ligne de cache, il se peut que plusieurs erreurs se soient accumulées, ce qui dépasse les capacités de correction de l'ECC. Dans ce cas, il existe une solution appelée le ''memory scrubbing'', qui permet de résoudre le problème au prix d'un certain cout en performance. Pour rappel, l'idée est de vérifier les lignes de caches régulièrement, pour éviter que les erreurs s'accumulent. Par exemple, on peut vérifier chaque ligne de cache toutes les N millisecondes, et corriger une éventuelle erreur lors de cette vérification. En faisant des vérifications régulières, on garantir que les erreurs n'ont pas le temps de s'accumuler, sauf en cas de malchance avec des erreurs très proches dans le temps. Il ne s'agit pas d'un rafraichissement mémoire, car les SRAM ne s'effacent pas), mais ça a un effet similaire. Et évidemment, le ''memory scrubbing'' a un cout en performance. On peut faire une comparaison avec le rafraichissement mémoire : les rafraichissement réguliers réduisent les performances, car cela fait des accès en plus. Des accès qui sont de plus timés à des instants bien précis qui ne sont pas forcément les plus adéquats. Il est possible qu'un rafraichissement ait lieu en même temps qu'un accès mémoire et le rafraichissement a la priorité, ce qui réduit les performances. La même chose arrive avec les vérifications du ''memory scrubbing''. Malgré tout, la technique a été utilisée sur les caches de certains processeurs commerciaux, dont des processeurs AMD Athlon et Athlon 64. Elle est surtout utilisable sur les caches L2/L3, pour lesquels le cout du pseudo-rafraichissement est acceptable. ==Un exemple de cache : le cache d'instruction== La grande majorité des processeurs utilise deux caches L1 séparés : un '''cache d'instructions''' dédié aux instructions, et un autre pour les données. Une telle organisation permet de charger une instruction tout en lisant une donnée en même temps. Notons que seul le cache L1 est ainsi séparé entre cache de données et d'instructions. Le cache d’instruction se situe en théorie entre l'unité de chargement et l'unité de décodage. En effet, ce cache prend en entrée une adresse et fournit une instruction. L'adresse est fournie par le ''program counter'', l'instruction est envoyée dans l'unité de décodage. Le cache se situe donc entre les deux. Le cache de données L1 est connecté au chemin de données, et notamment aux unités de communication avec la mémoire, pas au séquenceur. [[File:Caches L1 et positions dans le processeur.png|centre|vignette|upright=2.5|Caches L1 et positions dans le processeur]] Les deux caches sont reliés au processeur par des bus séparés, l'ensemble ressemble à une architecture Harvard, mais où les caches remplacent les mémoires RAM/ROM. Le cache d'instruction prend la place de la mémoire ROM et le cache de données prend la place de la mémoire RAM. Évidemment, il y a des niveaux de caches en dessous des caches de données/instruction, et ceux-ci contiennent à la fois données et instructions, les deux ne sont pas séparées dans des mémoires/caches séparés. Raison pour laquelle l'ensemble est appelé une '''architecture Harvard modifiée'''. Architecture Harvard, car l'accès aux données et instructions se font par des voies séparées pour le processeur, modifiée car la séparation n'est effective que pour le cache L1 et pas les autres niveaux de cache, et encore moins la RAM. Sur les processeurs modernes, il arrive très souvent que le processeur doive charger une instruction et lire/écrire une donnée en même temps. Et à vrai dire, c'est la règle plus que l'exception. L'usage d'une architecture Harvard modifiée permet cela très facilement : on peut accéder au cache d'instruction via un bus, et au cache de donnée avec l'autre ===Pourquoi scinder le cache L1 en cache d'instruction et de données=== L'usage d'un cache d’instruction séparé du cache de données est à contraster avec l'usage d'un cache L1 multiport unique, capable de mémoriser à la fois instructions et données. Les deux solutions sont possibles ont été utilisées. Les premiers processeurs avaient un cache L1 unique et multiport, mais ce n'est plus le cas sur les processeurs modernes, car les contraintes ne sont pas les mêmes. Le compromis à faire est celui entre deux petits caches rapides et un gros cache plus lent. Pour rappel, plus un cache est petit, plus il est rapide et chauffe moins. Donc au lieu d'utiliser, par exemple, un gros cache lent de 64 Kibioctets, on utilise deux caches de 32 kibioctets, plus rapides. La capacité totale est la même, mais le temps d'accès plus faible. En termes de temps d'accès, la meilleure solution est celle des deux caches simple port. Mais pour ce qui est de l'économie de circuits, c'est moins évident. Entre deux mémoires simple port et une mémoire multiport, la différence en termes de transistors est ambigüe et dépend de la capacité des caches. La différence est surtout notable pour les gros caches, moins pour les petits caches. Il faut aussi tenir compte de la capacité effective. Avec deux caches séparés, la répartition de la capacité du cache L1 est fixée une bonne fois pour toutes. Par exemple, avec un cache d'instruction de 32 KB et un cache de données de 32 KB, impossible d'allouer 40 KB aux données et 20 aux instructions. Alors qu'avec un cache L1 unique de 64 KB, on pourrait le faire sans soucis. La répartition se fait naturellement, en fonction de la politique de remplacement du cache et est proche de l'optimal. C'est là un désavantage des caches d'instructions/données séparés : une capacité effective moindre. Tout cela explique pourquoi le cache L1 est le seul à être ainsi scindé en deux, avec une séparation entre instructions et données : les contraintes au niveau du cache L1 et L2 ne sont pas les mêmes. Pour les caches L1, le temps d'accès est plus important que la capacité, ce qui favorise les caches séparés. Par contre, pour les caches L2/L3/L4, le temps d'accès n'est pas déterminant, alors que la capacité effective et l'économie en circuits sont significatives. ===La connexion des caches L1 avec le cache L2=== Pour les connexions avec le cache L2, tout dépend du processeur. Certains utilisent un cache L2 multiport, qui permet aux deux caches L1 de lire ou écrire dans le cache L2 simultanément. [[File:Cache d'instructions.png|centre|vignette|upright=1.5|Cache d'instructions.]] Si le cache L2 ne gère pas les accès simultanés, il n'y a qu'un seul bus relié aux caches L1 et au cache L2. On doit effectuer un arbitrage pour décider quel cache a la priorité, chose qui est réalisé par un circuit d'arbitrage spécialisé. [[File:Circuit d'arbitrage du cache.png|centre|vignette|upright=1.5|Circuit d'arbitrage du cache.]] Généralement, les caches d'instructions peuvent se permettre d'être plus petits que les caches de données, car les programmes sont souvent plus petits que les données manipulées. Songez que des programmes de quelques mébioctets peuvent parfois remplir la RAM avec plusieurs gibioctets de données. Lancez votre navigateur internet et ouvrez une page web un peu chargée, pour vous en convaincre ! ===Les spécificités du cache d'instruction : lecture seule, bloquant, etc=== Les instructions sont rarement modifiées ou accédées en écritures, contrairement aux données. Et cela permet d'utiliser un cache simplifié pour les instructions. Autant un cache généraliste doit permettre les lectures et écritures depuis le processeur (avec les échanges avec la RAM), autant un cache d'instruction peut se contenter des lectures provenant du CPU et des échanges avec la RAM. Le cache d'instructions est donc très souvent en « lecture seule » : le processeur ne peut pas écrire dedans, mais juste le lire ou charger des instructions dedans. Un cache d'instruction est donc plus simple qu'un cache pour les données : on peut retirer les circuits en charge de l'écriture (mais on doit laisser un port d'écriture pour charger les instructions dedans). Le gain en circuits permet d'utiliser un cache d'instruction plus gros ou au contraire de laisser de la place pour le cache de données. Le gain en termes de capacité compense alors un peu les inconvénients des caches séparés. Par contre, cela complique la gestion du code automodifiant, c'est-à-dire des programmes dont certaines instructions vont aller en modifier d'autres, ce qui sert pour faire de l'optimisation ou est utilisé pour compresser ou cacher un programme (les virus informatiques utilisent beaucoup de genre de procédés). Quand le processeur exécute ce genre de code, il ne peut pas écrire dans ce cache L1 d'instructions, mais doit écrire dans le cache L2 ou en RAM, avant de recharger les instructions modifiées dans le cache L1. Cela qui prend du temps et peut parfois donner lieu à des erreurs si le cache L1 n'est pas mis à jour. Les algorithmes de remplacement des lignes de cache optimaux pour les données ne le sont pas pour les instructions, de même que la taille optimale du cache, la taille des lignes de cache optimale, ou même les algorithmes de préchargement. Par exemple, pour le remplacement des lignes de cache, un simple algorithme LRU est presque optimal pour les instructions, autant il peut donner de mauvaises performances quand on manipule beaucoup de tableaux. Cela justifie d'utiliser des caches spécialisés pour chacune. On peut adapter le cache d'instruction à son contenu, ce qui le rend plus rapide ou plus petit à performance égale. Les caches d'instructions sont généralement des caches bloquants. Il ne servirait à rien de rendre un cache d'instruction non-bloquant, le cout en circuits ne se traduirait pas par une augmentation significative des performances. À l'opposé, les caches de données sont non-bloquants sur les architectures modernes, pour des raisons de performance. Ce qui rend la séparation assez intéressante, les deux caches ayant des besoins différents et des implémentations différentes, cela permet d'optimiser le cout en transistors des caches. ===L'impact du cache d'instruction sur les performances=== Sur les architectures conventionnelles, le cache d'instruction a plus d'impact sur les performances que le cache de données. La raison principale est que les instructions ont une meilleure localité spatiale et temporelle que pour les données. Pour la localité spatiale, les instructions consécutives se suivent en mémoire, alors que rien ne garantit que des données utilisées ensemble soient regroupées en mémoire. Pour localité temporelle, elle est très variable pour les données, mais très courante pour les instructions du fait de l'usage fréquent des boucles et des fonctions. : La présence de branchements atténue la localité temporelle des instruction, sauf que la majorité des branchements sautent à un endroit très proche, seuls les appels de fonction brisent la localité spatiale. La conséquence est qu'il arrive que certains CPU aient un cache L1 d'instruction plus gros que celui pour les données. On parle alors de '''cache L1 asymétriques'''. Un exemple est celui des processeurs AMD de microarchitecture Zen, dont le cache d'instruction était deux fois plus gros que le cache de données. Leur cache d'instruction faisait 64 kibioctets, contre seulement 32 pour le cache de données. D'ailleurs, il existe des processeurs assez extrêmes qui se contentent d'un cache d'instruction unique, sans cache de données. C'est le cas sur les processeurs vectoriels ou les GPU que nous verrons dans les chapitres de fin de ce wikilivres. De tels processeurs sont spécialisés dans la manipulation de tableaux de données, traitement qui a une faible localité temporelle. En conséquence, utiliser un cache de données n'est pas vraiment utile, voire peu être contreproductif, alors qu'un cache d’instruction fonctionne parfaitement. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Compléments sur les mémoires de masse | prevText=Compléments sur les mémoires de masse | next=Le préchargement | nextText=Le préchargement }} </noinclude> 50h3vy245ajuodgcjmbs9acmpdlq036 765121 765120 2026-04-26T15:32:01Z Mewtow 31375 /* L'exemple des processeurs 386 et du contrôleur de cache 82385 */ 765121 wikitext text/x-wiki Le cache est une mémoire intercalée entre la mémoire et un processeur, plus rarement à l'intérieur d'un périphérique. Il est souvent fabriquée avec de la mémoire SRAM, parfois avec de l'eDRAM. Sans lui, on se croirait à l'âge de pierre tellement nos PC seraient lents ! En effet, la mémoire est très lente comparée au processeur. Le temps mis pour accéder à la mémoire est du temps durant lequel le processeur n'exécute pas d'instruction (sauf cas particuliers impliquant un pipeline). Pour diminuer ce temps d'attente, il a été décidé d'intercaler une mémoire petite mais rapide, entre le processeur et la mémoire. Ainsi, le processeur accède à un cache très rapide plutôt qu'à une RAM beaucoup plus lente. ==L'accès au cache== Le cache contient une copie de certaines données présentes en RAM. La copie présente dans le cache est accessible bien plus rapidement que celle en RAM, vu que le cache est plus rapide. Mais seule une petite partie de ces données sont copiées dans le cache, les autres données devant être lues ou écrites dans la RAM. Toujours est-il que le cache contient une copie des dernières données accédées par le processeur. Une donnée est copiée dans la mémoire cache quand elle est lue ou écrite par le processeur. Le processeur conserve une copie de la donnée dans le cache après son premier accès. Les lectures/écritures suivantes se feront alors directement dans le cache. Évidemment, au fur et à mesure des accès, certaines données anciennes sont éliminées du cache pour faire de la place aux nouveaux entrants, comme nous le verrons plus tard. [[File:Principe d'une mémoire cache.gif|centre|vignette|upright=2|Principe d'une mémoire cache.]] La mémoire cache est invisible pour le programmeur, qui ne peut pas déceler celles-ci dans l'assembleur. Les accès mémoire se font de la même manière avec ou sans le cache. La raison à cela est que le cache intercepte les accès mémoire et y répond s'il en a la capacité. Par exemple, si le cache intercepte une lecture à une adresse et que le contenu de cette adresse est dans le cache, le cache va outrepasser la mémoire RAM et la donnée sera envoyée par le cache au lieu d'être lue en RAM. par contre, si un accès se fait à une adresse pour laquelle le cache n'a pas la donnée, alors l'accès mémoire sera effectué par la RAM de la même manière que si le cache n'était pas là. [[File:Accès au cache.png|centre|vignette|upright=2|Accès au cache]] ===Les succès et défauts de caches=== Tout accès mémoire est intercepté par le cache, qui vérifie si la donnée demandée est présente ou non dans le cache. Si la donnée voulue est présente dans le cache, on a un '''succès de cache''' (''cache hit'') et on accède à la donnée depuis le cache. Sinon, c'est un '''défaut de cache''' (''cache miss'') et on est obligé d’accéder à la RAM. Les défauts de cache peuvent avoir plusieurs origines. Tout ce qu'il faut savoir est que lorsque le processeur accède à une donnée ou une instruction pour la première fois, il la place dans la mémoire cache car elle a de bonnes chances d'être réutilisée prochainement. La raison à cela est qu'un programme a tendance à réutiliser les instructions et données qui ont été accédées dans le passé : c'est le ''principe de localité temporelle''. Bien évidement, cela dépend du programme, de la façon dont celui-ci est programmé et accède à ses données et du traitement qu'il fait, mais c'est souvent vrai en général. La première cause des défauts de cache est liée à la taille du cache. À force de charger des données/instructions dans le cache, le cache fini par être trop petit pour conserver les anciennes données. Le cache doit bien finir par faire de la place en supprimant les anciennes données, qui ont peu de chances d'être réutilisées. Ces anciennes données éliminées du cache peuvent cependant être accédées plus tard. Tout prochain accès à cette donnée mènera à un cache miss. C'est ce qu'on appelle un ''Capacity Cache Miss'', ou encore '''défaut de capacité'''. Les seules solutions pour éviter cela consistent à augmenter la taille du cache ou à optimiser le programme exécuté (voir plus bas). Une autre raison pour un défaut est donc la suivante. Lorsqu'on exécute à une instruction ou qu'on accède à donnée pour la première fois, celle-ci n'a pas encore été chargée dans le cache. Le défaut de cache est inévitable : ce genre de cache miss s'appelle un ''Cold Miss'', ou encore un '''défaut à froid'''. De tels défauts sont presque impossibles à éliminer, sauf à utiliser des techniques de préchargement qui chargent à l'avance des données potentiellement utiles. Ces méthodes de préchargement se basent sur le principe de localité spatiale, à savoir le fait que les programmes ont tendance à accéder à des données proches en mémoire. Pour donner un exemple, les instructions d'un programme sont placées en mémoire dans l’ordre dans lequel on les exécute : la prochaine instruction à exécuter est souvent placée juste après l'instruction en cours (sauf avec les branchements). Quand on accède à une donnée ou une instruction, le cache peut précharger les données adjacentes pour en profiter. Nous parlerons de ces techniques de préchargement dans un chapitre dédié, vers la fin du cours. ===Le fonctionnement du cache, vu du processeur=== Vu du processeur, le cache prend en entrée toutes les informations nécessaires pour effectuer un accès mémoire : des signaux de commande, une adresse et la donnée à écrire si besoin. Tout cela est passé en entrée du cache, celui-ci répondant aux accès mémoire via divers bits de contrôles, que le processeur peut lire à souhait. Le cache fournit aussi la donnée à lire, pour les lectures, sur une sortie, connectée directement au bus mémoire/processeur. Globalement, le cache a une capacité limitée, mais il prend en entrée des adresses complètes. Par exemple, sur un processeur 64 bits, le cache prend en entrée des adresses de 64 bits (sauf si optimisations), même si le cache en question ne fait que quelques mébioctets. Les caches sont souvent des mémoires multiports, surtout sur les processeurs récents. Les caches simple port sont rares, mêmes s'ils existent et ont existé par le passé. les caches double port sont eux plus fréquents, et ont généralement un port d'écriture séparé du port de lecture. Mais les caches récents ont plusieurs ports de lecture/écriture et sont capables de gérer plusieurs accès mémoire simultanés. Les données présentes dans le cache sont (pré)chargées depuis la mémoire, ce qui fait que toute donnée dans le cache est la copie d'une donnée en mémoire RAM. Le cache doit faire la correspondance entre une donnée du cache et l'adresse mémoire correspondante. Du point de vue du fonctionnement, on peut voir le cache comme une sorte de table de correspondance, qui mémorise des données, chacune étant associée à son adresse mémoire. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Cela vaut du point de vue du processeur, le fonctionnement interne du cache étant quelque peu différent selon le cache. Il existe des caches dont le fonctionnement interne est bien celui d'une table de correspondance matérielle, d'autres qui sont beaucoup plus optimisés. [[File:Fonctionnement d'une mémoire associative à correspondance.png|centre|vignette|upright=2|Fonctionnement simplifié d'une mémoire cache : les adresses sont dans la colonne de gauche, les données sont dans la colonne de droite. On voit qu'on envoie l'adresse au cache, que celui-ci répond en renvoyant la donnée associée.]] ==La performance des mémoires caches== L'analyse de la performance des mémoires caches est plus riche pour celle des autres mémoires. Sa performance dépend de beaucoup de paramètres, mais on peut cependant citer les principaux. Les deux premiers sont tout bonnement sa latence et son débit, comme pour n'importe quelle autre mémoire. La latence est plus importante que son débit, car le processeur est généralement plus rapide que le cache et qu'il n'aime pas attendre. Mais le critère le plus important pour un cache est sa capacité à empêcher des accès mémoire, son efficacité. Plus les accès mémoire sont servis par le cache au lieu de la RAM, meilleures seront les performances. Pour résumer, la performance d'un cache est surtout caractérisée par deux métriques : le taux de défaut, qui correspond à l’efficacité du cache, et la latence du cache. ===Le taux de succès/défaut=== Le '''taux de succès''' (hit ratio) est un premier indicateur des performances du cache, mais un indicateur assez imparfait. C'est le pourcentage d'accès mémoire qui ne déclenchent pas de défaut de cache. Plus il est élevé, plus le processeur accède au cache à la place de la RAM et plus le cache est efficace. Certains chercheurs préfèrent utiliser le '''taux de défauts''', à savoir le pourcentage d'accès mémoire qui entraînent un défaut de cache. Plus il est bas, meilleures sont les performances. Le taux de défaut est relié au taux de succès par l'équation <math>T_\text{succes} = 1 - T_\text{defaut}</math>. Par définition, il est égal à : : <math>\text{Taux de défauts de cache} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d’accès mémoires}}</math> Plutôt que de comparer le nombre de défauts/succès de cache au nombre d'accès mémoire, il est aussi possible de diviser le nombre de défauts par le nombre total d'instructions. On obtient alors le '''taux de défauts/succès par instruction''', une autre métrique utile. Par définition, elle est égale à : : <math>\text{Taux de défauts par instruction} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d'instructions}} = \text{Taux de défauts de cache} \times \frac{\text{Nombre d’accès mémoires}}{\text{Nombre d'instructions}}</math> Si certains défauts de cache sont inévitables quel que soit le cache, comme les défauts à froids, mentionnés plus haut, d'autres défauts peuvent être évités en augmentant la capacité du cache. C'est le cas des défauts de capacité qui sont causés par un accès à une donnée qui a été éliminée du cache faute de place. Plus le cache est gros, moins il a de chances d'être rempli, moins il doit rapatrier de données, plus son taux de succès augmente. Mais nous reviendrons sur le lien entre taille du cache et taux de défaut plus bas. Le taux de succès ne dépend pas que du cache, mais aussi de la conception des programmes exécutés. Une bonne utilisation du cache (ainsi que de la mémoire virtuelle) repose sur le programmeur qui doit prendre en compte les principes de localités dès la conception de ses programmes. Par exemple, un programmeur peut parfaitement tenir compte du cache au niveau de son algorithme : on peut citer l'existence des algorithmes ''cache oblivious'', qui sont conçus pour être optimaux quelle que soit la taille du cache. Le programmeur peut aussi choisir ses structures de données de manière à améliorer la localité. Par exemple, un tableau est une structure de donnée respectant le principe de localité spatiale, tandis qu'une liste chaînée ou un arbre n'en sont pas (bien qu'on puisse les implémenter de façon à limiter la casse). D'autres optimisations sont parfois possibles : par exemple, le sens de parcours d'un tableau multidimensionnel peut faire une grosse différence. Cela permet des gains très intéressants pouvant se mesurer avec des nombres à deux ou trois chiffres. Je vous recommande, si vous êtes programmeur, de vous renseigner le plus possible sur les optimisations de code ou algorithmiques qui concernent le cache : il vous suffira de chercher sur Google. Il y a une citation qui résume bien cela, prononcée par un certain Terje Mathisen. Si vous ne le connaissez pas, cet homme est un vieux programmeur (du temps durant lequel on codait encore en assembleur), grand gourou de l’optimisation, qui a notamment travaillé sur le moteur de Quake 3 Arena. {{BlocCitation|Almost all programming can be viewed as an exercise in caching.|auteur=Terje Mathisen}} ===La latence moyenne d'un cache=== Le temps mis pour lire ou écrire une donnée varie en présence d'un cache. Certaines lectures/écritures vont atterrir directement dans le cache (succès) tandis que d'autres devront aller chercher leur contenu en mémoire RAM (défaut de cache). Dans tous les cas, qu'il y ait défaut ou non, le cache sera consulté et mettra un certain temps à répondre, égal au temps de latence du cache. Tous les accès mémoires auront donc une durée au moins égale au temps de latence du cache, qui sera notée <math>T_c</math>. En cas de succès, le cache aura effectué la lecture ou l'écriture, et aucune action supplémentaire n'est requise. Ce qui n'est pas le cas en cas de défaut : le processeur devra aller lire/écrire la donnée en RAM, ce qui prend un temps supplémentaire égal au temps de latence de la mémoire RAM. Un défaut ajoute donc un temps, une pénalité, à l'accès mémoire. Dans ce qui suivra, le temps d'accès à la RAM sera noté <math>T_m</math>. Fort de ces informations, nous pouvons calculer le temps de latence moyen d'un accès mémoire, qui est la somme du temps d'accès au cache (pour tous les accès mémoire), multiplié par le temps lié aux défauts. On a alors : : <math>T = T_c + \text{Taux de défaut} \times T_m</math> On voit que plus le taux de succès est élevé, plus le temps de latence moyen sera bas, et inversement. Ce qui explique l'influence du taux de succès sur les performances du cache, influence assez importante sur les processeurs actuels. De nos jours, le temps que passe le processeur dans les défauts de cache devient de plus en plus un problème au fil du temps, et gérer correctement le cache est une nécessité, particulièrement sur les processeurs multi-cœurs. Il faut dire que la différence de vitesse entre processeur et mémoire est tellement importante que les défauts de cache sont très lents : alors qu'un succès de cache va prendre entre 1 et 5 cycles d'horloge, un cache miss fera plus dans les 400-1000 cycles d'horloge. Tout ce temps sera du temps de perdu que le processeur aura du mal à mitiger. Autant dire que réduire les défauts de cache est beaucoup plus efficace que d'optimiser les calculs effectués par le processeur (erreur courante chez de nombreux programmeurs, notamment débutants). ===L'impact de la taille du cache sur le taux de défaut et la latence=== Il y a un lien entre taille du cache, taux de défaut, débit binaire et latence moyenne. Globalement, plus un cache est gros, plus il est lent. Simple application de la notion de hiérarchie mémoire vue il y a quelques chapitres. Les raisons à cela sont nombreuses, mais nous ne pouvons pas les aborder ici, car il faudrait que nous sachions comment fonctionne un cache et ce qu'il y a à l'intérieur, ce qui sera vu dans la suite du chapitre. Toujours est-il que la latence moyenne d'un cache assez gros est assez importante. De même, le débit binaire d'un cache diminue avec sa taille, mais dans une moindre mesure. Les petits caches ont donc un gros débit binaire et une faible latence, alors que c'est l'inverse pour les gros caches. Une grande capacité de cache améliore le taux de succès, mais cela se fait au détriment de son temps de latence et de son débit, ce qui fait qu'il y a un compromis assez difficile à trouver entre taille du cache, latence et débit. Il peut arriver qu'augmenter la taille du cache augmente son temps d'accès au point d’entraîner une baisse de performance. Par exemple, les processeurs Nehalem d'Intel ont vus leurs performances dans certains jeux vidéos baisser de 2 à 3 %, malgré de nombreuses améliorations architecturales, parce que la latence du cache L1 avait augmentée de 2 cycles d'horloge. Pour avoir une petite idée du compromis à faire, regardons la relation entre taille du cache et taux de défaut. Il existe une relation approximative entre ces deux variables, appelée la '''loi de puissance des défauts de cache'''. Elle donne le nombre total de défaut de cache en fonction de la taille du cache et de deux autres paramètres. Voici cette loi : : <math>\text{Taux de défauts de cache} \approx K \times \text{Taille du cache}^{- \alpha }</math>, avec <math>K</math> et <math>\alpha</math> deux coefficients qui dépendent du programme exécuté. Le coefficient <math>\alpha</math> est généralement compris entre 0.3 et 0.7, guère plus, et varie suivant le programme exécuté. Précisons que cette loi ne marche que si le cache est assez petit par rapport aux données à utiliser. Pour un cache assez gros et des données très petites, la relation précédente est mise en défaut. Pour s'en rendre compte, il suffit d'étudier le cas extrême où toutes les données nécessaires tiennent dans le cache. Dans ce cas, il n'y a qu'un nombre fixe de défauts de cache : autant qu'il faut charger de données dans le cache. Le nombre de défauts de cache observé dans cette situation n'est autre que le coefficient <math>K</math> de la situation précédente, mais il n'y a aucune dépendance entre taux de défaut et taille du cache. L'origine de cette relation s'explique quand on regarde combien de fois chaque donnée est réutilisée lors de l’exécution d'un programme. La plupart des données finissent par être ré-accédées à un moment ou un autre et il se passe un certain temps entre deux accès à une même donnée. Sur la plupart des programmes, les observations montrent que beaucoup de réutilisations de données se font après un temps très court et qu'inversement, peu de ré-accès se font après un temps inter-accès long. Si on compte le nombre de réutilisation qui ont un temps inter-accès bien précis, on retrouve une loi de puissance identique à celle vue précédemment : : <math>\text{Nombre de réaccès avec un temps inter-accès égal à t} \approx K \times t^{- \beta}</math>, avec t le temps moyen entre deux réutilisations. Le coefficient <math>\beta</math> est ici compris entre 1.7 et 1.3. De manière générale, les coefficients <math>\alpha</math> et <math>\beta</math> sont reliés par la relation <math>\alpha = 1 - \beta</math>, ce qui montre qu'il y a un lien entre les deux relations. Précisons cependant que la loi de puissance précédente ne vaut pas pour tous les programmes informatiques, mais seulement pour la plupart d’entre eux. Il n'est pas rare de trouver quelques programmes pour lesquels les accès aux données sont relativement prédictibles et où une bonne optimisation du code fait que la loi de puissance précédente n'est pas valide. La loi de puissance des défauts de cache peut se démontrer à partir de la relation précédente, sous certaines hypothèses. Si un suppose que le cache est assez petit par rapport aux données, alors les deux relations sont équivalentes. L'idée qui se cache derrière la démonstration est que si le temps entre deux accès à une donnée est trop long, alors la donnée accédée aura plus de chance d'être rapatriée en RAM, ce qui cause un défaut de cache. La chance de rapatriement dépend de la taille du cache, un cache plus gros peut conserver plus de données et a donc un temps avant rapatriement plus long. ==Les lignes de cache et leurs tags== Du point de vue du processeur, les lectures et écritures se font mot mémoire par mot mémoire. Un processeur avec des entiers de 64 bits recoit des données de 64 bits de la part du cache, et y écrit des mots de 64 bits. Mais quand on regarde comment sont stockées les données à l'intérieur du cache, les choses sont différentes. ===Les lignes de cache=== Les données sont mémorisées dans le cache par blocs de plusieurs bytes, d'environ 64 à 256 octets chacun, qui portent le nom de '''lignes de cache'''. Les lignes de cache sont l'unité de stockage que l'on trouve à l'intérieur du cache, mais elles servent aussi d'unité de transaction avec la mémoire RAM. Sur les caches actuels, on transfère les données entre le cache et la RAM ligne de cache par ligne de cache, dans la limite de la taille du bus mémoire. Mais d'autres caches plus anciens permettaient de faire des transferts plus fins. C’est-à-dire qu'on pouvait mettre à jour quelques octets dans une ligne de cache sans avoir à la recopier intégralement depuis ou dans la mémoire RAM. En théorie, on pourrait imaginer des caches où les données sont stockées différemment, où l'unité serait le mot mémoire, par exemple. Par exemple, sur un processeur 64 bits, on aurait une ligne de cache de 64 bits. Cela aurait l'avantage de la simplicité : les transferts entre le processeur et la mémoire serait de même taille, l'intérieur du cache ressemblerait à son interface montrée au processeur. Mais cela aurait quelques défauts qui sont compensés par l'organisation en lignes de cache de grande taille. Le premier avantage des lignes de cache est lié à la localité spatiale, la tendance qu'on les programmes à accéder à des données proches les unes des autres. Des accès mémoires consécutifs ont tendance à se faire à des adresses proches, qui ont de bonnes chances d'être dans la même ligne de cache. Et des accès consécutifs à une même ligne de cache sont plus rapides que des accès à deux lignes distinctes. Une autre raison est tout simplement que cela simplifie considérablement la circuiterie du cache. Pour une capacité identique, il vaut mieux avoir peu de lignes de cache assez grosses, que beaucoup de petites lignes de cache. La raison est que les circuits du cache, comme le décodeur, l'encodeur et autres, ont moins de sorties et sont donc plus simples. ===L'alignement des lignes de cache=== Les lignes de cache sont des blocs de plusieurs dizaines à centaines de bytes, dont la taille est presque toujours une puissance de deux. De plus, les lignes de cache sont alignées en mémoire. Nous avions déjà abordé la notion d'alignement mémoire dans un chapitre précédent, mais le concept d'alignement des lignes de cache est quelque peu différent. Quand nous avions parlé d'alignement auparavant, il s'agissait de l'alignement des données manipulées par le processeur, qui faisait partie du jeu d'instruction du processeur. Ici, nous parlons d'un alignement totalement différent, invisible pour le programmeur, sans lien avec le jeu d’instruction. Voyons de quoi il retourne. Concrètement, cela veut dire que du point de vue du cache, la RAM est découpée en blocs qui font la même taille qu'une ligne de cache, aux positions prédéterminées, sans recouvrement entre les blocs. Par exemple, pour un cache dont les lignes de cache font 256 octets, le premier bloc est à l'adresse 0, le second est 256 octets plus loin, c'est à dire à l'adresse 256, le troisième à l'adresse 512, la quatrième à l'adresse 768, etc. Une ligne de cache de 256 octets contiendra une donnée provenant d'un bloc de RAM de 256 octets, dont l'adresse est systématiquement un multiple de 256. Il n'est pas possible qu'une ligne de cache contienne un bloc de 256 octets dont l'adresse du premier octet serait l'adresse 64, ou l'adresse 32, par exemple. En clair, les adresses de ces blocs sont des multiples de la taille de la ligne de cache, de la taille des blocs. Cela rappelle les contraintes d'alignement vues dans le chapitre "Le modèle mémoire : alignement et boutisme", mais appliquées aux lignes de cache. L'alignement des lignes de cache a des conséquences pratiques pour la conception des caches. Notons qu'il est en théorie possible d'avoir des caches dont les lignes de cache ne sont pas alignées, mais cela poserait des problèmes majeurs. Il serait en effet possible qu'une donnée soit présente dans deux lignes de cache à la fois. Par exemple, prenons le cas où une ligne de cache de 256 commence à l'adresse 64 et une autre ligne de cache commence à l'adresse 0. L'adresse 128 serait dans les deux lignes de cache ! Et cela poserait des problèmes lors des lectures, mais encore plus lors des écritures. C'est pour éviter ce genre de problèmes que les lignes de cache sont alignées avec la mémoire RAM dans tous les caches existants. L'alignement des lignes de cache est une chose que les programmeurs doivent parfois prendre en compte quand ils écrivent du code ultra-optimisé, destiné à des programmes demandant des performances extrêmes. Il arrive que les contraintes d'alignement posent des problèmes. Nous avions vu dans le chapitre sur le boutisme et l'alignement qu'il valait mieux gérer l'alignement des variables des structures de données, pour éviter les accès non-alignés avec le bus mémoire. La même chose est possible, mais pour l'alignement avec des lignes de cache. Typiquement, l'idéal est que, pour une structure de donnée, on puisse en mettre un nombre entier dans une ligne de cache. Ou alors, si la structure est vraiment grande, que celle-ci occupe un nombre entier de lignes de cache. Si ce n'est pas le cas, il y a un risque d'accès non-alignés, c'est à dire qu'une structure se retrouve à cheval sur deux lignes de cache, avec les défauts que cela implique. ===Le tag d'une ligne de cache=== Plus haut, nous avions dit que le cache mémorise, pour chaque ligne de cache, l'adresse RAM associée. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Mais du fait de l'organisation du cache en lignes de cache de grande taille, qui sont de plus alignées en mémoire, il faut nuancer cette affirmation. Le cache ne mémorise pas la totalité de l'adresse, ce qui serait inutile. L'alignement des lignes de cache en RAM fait que les bits de poids faible de l'adresse ne sont pas à prendre en compte pour l'association adresse-ligne de cache. Dans ces conditions, on mémorise seulement la partie utile de l'adresse mémoire correspondante, qui forme ce qu'on appelle le '''tag'''. Le reste de l'adresse indique quelle est la position de la donnée dans la ligne de cache. Par exemple, prenons le cas où le processeur gère des nombres entiers de 64 bits (8 octets) et des lignes de cache de 128 octets : chaque ligne de cache contient donc 16 entiers. Si le processeur veut lire ou écrire un entier bien précis, il doit préciser sa place dans la ligne de cache. Et ce sont les bits de l'adresse mémoire non-inclus dans le cache qui permettent de faire ça. En clair, une adresse mémoire à lire/écrire est interprété par le cache comme la concaténation d'un tag et de la position de la donnée dans la ligne de cache correspondante. [[File:Adressage d'un cache totalement associatif.png|centre|vignette|upright=2|Adressage d'un cache totalement associatif]] Le cache est donc une grande table de correspondance entre tags et lignes de cache. Lors d'un accès mémoire, le cache extrait le tag de l'adresse à lire ou écrire, et le compare avec les tags de chaque ligne de cache. Si une ligne contient ce tag, alors c'est que cette ligne correspond à l'adresse, et c'est un défaut de cache sinon. Lors d'un succès de cache, la ligne de cache est lue depuis le cache et envoyée à un multiplexeur qui sélectionne la donnée à lire dans la ligne de cache. Le fonctionnement est similaire pour une écriture : la donnée à écrire passe dans un démultiplexeur, qui envoie la donnée au bon endroit dans la ligne de cache sélectionnée. [[File:Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.png|centre|vignette|upright=2|Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.]] ===Le contenu d'une ligne de cache=== Dans ce qui va suivre, nous allons considérer que chaque ligne de cache mémorise son tag, les données de la ligne de cache proprement dit, et quelques bits de contrôle annexes qui varient suivant le cache considéré. [[File:Tag d'une ligne de cache.png|centre|vignette|upright=2|Tag d'une ligne de cache.]] Les caches modernes incluent de nombreux bits de contrôle, mais deux d'entre eux sont communs à presque tous les caches modernes : le bit ''Dirty'' et le bit ''Valid''. Le '''bit ''Valid''''' indique si la ligne de cache contient des données valides ou non. Si le bit ''Valid'' est à 0, la ligne de cache est en état valide, à savoir qu'elle contient des données et n'est pas vide. Par contre, si ce bit est à 1, la ligne de cache est invalide et son contenu ne peut pas être lu ou écrit. L'utilité de ce bit est qu'il permet d'effacer une ligne de cache très rapidement : il suffit de mettre ce bit à 0. Il existe des situations où le cache doit être effacé, on dit alors qu'il est invalidé. Une section de ce chapitre sera dédié à l'invalidation du cache. Le '''bit ''Dirty''''' indique qu'une ligne de cache a été modifiée. Par modifiée, on veut dire que le processeur a écrit dedans, qu'il a modifié la ligne de cache. Mais attention : si la donnée a été modifiée dans le cache, la modification n'est pas forcément propagée en mémoire RAM. Le bit ''dirty'' indique si c'est le cas, si l'écriture a été propagée en mémoire RAM. Il précise que la ligne de cache contient des données modifiées, alors que la RAM a des données initiales non-modifiées. Une ligne de cache avec un bit ''dirty'' à 1 est dite ''dirty'', par métonymie. Nous verrons cela en détail dans la section sur les caches ''write-back'' et ''write-through''. Les caches modernes ajoutent des '''bits de détection/correction d'erreur''' dans les bits de contrôle. Pour rappel, les codes de détection/correction d'erreur permettent de se prémunir contre des erreurs matérielles, qui corrompent les données stockées dans une mémoire, ici une mémoire cache. Ils ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Nous reviendrons dessus dans une section ultérieur de ce chapitre. Sur certains caches assez anciens, on pouvait transférer les lignes de caches morceaux par morceaux. Ces caches avaient des lignes de cache divisées en sous-secteurs, ces sous-secteurs étant des morceaux de ligne de cache qu'on pouvait charger indépendamment les uns des autres (mais qui sont consécutifs en RAM). Chaque secteur avait ses propres bits de contrôle, mais le tag était commun à tous les secteurs. [[File:Cache à secteurs.png|centre|vignette|upright=2.5|Cache à secteurs.]] : Dans ce qui va suivre, le terme "ligne de cache" désignera soit un bloc de données copiées depuis la RAM d'une taille de 64/128/256/... octets, soit la concaténation de ces données avec le tag et des bits de contrôle. Les deux définitions ne sont pas équivalentes, mais l'usage a entériné cet abus de langage. Et il faut avouer que cela rend les explications du chapitre plus simples. ==Les instructions de contrôle du cache== Plus haut, nous avions dit que le cache est totalement transparent du point de vue du programmeur. Le cache contient des copies de données en RAM, le programmeur n'a rien à faire pour utiliser le cache correctement. Mais la réalité est que pour des raisons diverses, des processeurs incorporent des '''instructions de contrôle du cache'''. Il s'agit d’instructions qui agissent sur le contenu du cache. Elles existent pour des raisons diverses qu'on détaillera plus bas, mais il s'agit globalement d'une question de performances ou de nécessité pour le système d'exploitation. ===Les instructions de préchargement=== La première instruction de contrôle du cache est une '''instruction de préchargement''', qui demande à charger un bloc de données dans le cache. Elle prend en opérande une adresse mémoire, et le contenu de cette adresse est chargé dans une ligne de cache. Bien sûr, des contraintes d'alignement sont à prendre en compte : on charge un bloc de la même taille qu'une ligne de cache, aligné en mémoire sur la taille du bloc, qui contient l'adresse. L'instruction de préchargement n'est utile que si l'instruction est exécutée bien avant que la donnée ne soit utilisée/lue/écrite. Cela permet de charger une donnée dans le cache à l'avance, d'où le nom de préchargement donné à cette technique. Mais les processeurs modernes gérent des techniques de préchargement automatique, qui ne requièrent pas d'instructions de préchargement. Le préchargement automatique et les instructions de préchargement sont deux solutions complémentaires, mais qui peuvent se marcher sur les pieds. Nous en reparlerons dans le prochain chapitre, qui sera dédié au préchargement automatique. Il faut noter que les instructions de préchargement peuvent être ignorées par le processeur. Sous certaines conditions, le processeur peut décider que l'instruction de préchargement ne sera pas exécutée. Par exemple, il ne va pas précharger une donnée déjà présente dans le cache. Ou encore, si le bus mémoire est occupé, il ne va pas exécuter le préchargement, par manque de ressources matérielles. ===Les instructions d'invalidation et de ''flush''=== Les instructions ''flush'' regroupent deux types d'instructions qui sont souvent utilisées en même temps. Il s'agit des instructions d'invalidation et de nettoyage (''clean''). Les deux termes proviennent de la terminologie ARM, il n'y a pas de terminologie standardisé pour les noms de ces instructions. Dans les grandes lignes, elles permettent de vider le cache, à savoir de rapatrier son contenu en RAM et de réinitialiser le cache à zéro. Elles sont utilisées par le système d'exploitation lors des commutations de contexte, à savoir quand on passe d'un programme à un autre. Elles sont aussi utilisées lors des appels systèmes et routines d'interruption/exception. L'idée est de vider le cache avant d'exécuter un nouveau programme ou une nouvelle routine. Le nouveau programme aura accès à un cache tout propre, les données de l'ancien programme auront été retirée du cache. Les '''instructions ''clean''''' recopient le contenu de la ligne de cache en RAM. Elles forcent la recopie immédiatement de la ligne de cache en mémoire RAM. Pour faire leur travail, elle vérifient si la ligne de cache a été modifiée, avant de la recopier en RAM. Et pour cela, ils vérifient le bit de contrôle ''dirty'', qui est mis à 1 après une première écriture. Si ce bit est à 0, alors pas besoin de recopier la ligne de cache : elle n'a pas été modifiée, la RAM a déjà la bonne copie. Mais s'il est à 1, le cache et la RAM n'ont pas le même contenu, la recopie s'exécute. Les '''instructions d'invalidation''' permettent d'invalider une ligne de cache, à savoir d'effacer son contenu. Nous verrons à quoi servent ces instructions dans la section sur les changement de processus. Invalider une ligne de cache est une opération optimisée : le cache n'est en réalité pas réellement effacé. À la place, le bit ''Valid'' de chaque ligne de cache est juste mis à 0. Il faut noter que l'invalidation efface les lignes de cache sans se préoccuper de leur contenu. Elle se moque qu'une ligne de cache contienne une donnée modifiée, ''dirty'' ou quoique ce soit : la ligne de cache est effacée, point. Il est possible d'invalider une ligne de cache en fournissant une adresse mémoire, mais il est aussi possible d'invalider le cache tout entier. Le choix entre les deux dépend du mode d'adressage de l'instruction d'invalidation. Parfois, il existe une instruction séparée pour invalider tout le cache, et une autre pour invalider une ligne de cache bien précise. Des instructions séparées sont parfois disponibles pour invalider les caches de données et d'instructions, parfois aussi la TLB (un cache qu'on verra dans quelques chapitres). Il est possible de n'invalider que le cache L1, voire le cache L2. Il faut noter que l'invalidation efface tout le cache, mais ne se préoccupe pas de vérifier si les données ont été modifiées dans le cache. Pour certains caches, comme le cache d'instruction, ce n'est pas un problème, vu qu'il est en "lecture seule". Mais pour les caches de données, les données modifiées sont perdues en cas d'invalidation. Heureusement, il existe des instructions d'invalidation qui fusionnent une instruction ''clean'' et une instruction d'invalidation. Il s'agit d''''instructions d'invalidation spéciales'''. ===Les instructions d'optimisation : instructions non-temporelles et écritures optimisées=== Les '''instructions mémoire non-temporelles''' contournent complètement le cache. Par exemple, une lecture peut lire une donnée, mais celle-ci ne sera pas chargée dans le cache, elle passe directement de la RAM vers les registres. Une section entière de ce chapitre sera dédiée au contournement du cache, à savoir aux situations où les accès mémoire doivent passer directement du processeur à la RAM sans passer par le cache. D'autres instructions assez rares incorporent des indications pour le cache. Par exemple, l'instruction ''load last'' des processeurs POWER PC implique que la donnée ne sera utilisée qu'une seule fois. Elle est donc chargée dans le cache, mais la ligne de cache est configurée de manière à être remplacée très rapidement, typiquement avec une valeur de LRU/LFU adéquate. La donnée est bien chargée dans le cache, au cas où elle doive être relue suite à une mauvaise prédiction de branchement ou autre, chose qu'une lecture non-temporelle (qui contourne le cache) ne fait pas. Des indications de ce type sont appelées des '''''cache hint'''''. L''''instruction ''flush''''' permet de préciser qu'une ligne de cache contient une donnée inutile, qui ne sera pas réutilisée par le programme. Pas besoin de la conserver dans le cache, elle peut laisser sa place à des données plus utiles. Or, sans indication, les algorithmes de remplacement d'une ligne de cache risquent de conserver cette donnée trop longtemps, ce qui entraine une certaine pollution du cache par des données inutiles. Une autre instruction est elle beaucoup plus importante : celle de '''pré-allocation sur écriture'''. Elle sert dans le cas où une ligne de cache est complétement écrite. Par exemple, imaginons qu'on veuille écrire dans une portion de mémoire. Si celle-ci n'est pas dans le cache, le processeur va charger une ligne de cache complète depuis la RAM, écrire dans la ligne de cache, puis recopier la ligne de cache modifiée en mémoire RAM. Une écriture en RAM demande donc de faire une lecture et une écriture. Mais les instructions de pré-allocation sur écriture permettent de prévenir qu'une ligne de cache sera intégralement écrite, et qu'il n'y a donc pas besoin de lire celle-ci depuis la RAM. Notons que l'instruction d'écriture qui suit n'est pas une écriture non-temporelle, vu que les données sont écrites dans la ligne de cache, qui est ensuite envoyée en mémoire RAM dès que nécessaire. De plus, les données écrites peuvent ensuite être relue depuis le cache si nécessaire. Enfin, certains processeurs MIPS incorporent une instruction pour modifier le tag d'une ligne de cache. Elles servent à optimiser les copies mémoire, à savoir quand on copie un bloc de données d'un endroit à un autre. L'idée est de charger le bloc de données dans le cache avec une instruction LOAD/PREFETCH, de modifier le tag pour qu'il pointe vers l'adresse à écrire, et de laisser faire le cache pour que l'écriture se fasse en RAM. Mais les contraintes pour utiliser cette instruction sont assez drastiques : les données doivent être alignées sur la taille d'une ligne de cache, le bloc de départ et d'arrivée (l'original versus la copie) ne doivent pas se recouvrir, etc. ==L'associativité des caches et leur adressage implicite== Lorsqu'on souhaite accéder au cache, il faut trouver quelle est la ligne de cache dont le tag correspond à l'adresse demandée. On peut classifier les caches selon leur stratégie de recherche de la ligne correspondante en trois types de caches : totalement associatifs, directement adressés (''direct mapped'') et associatifs par voie. ===Les caches totalement associatifs=== Avec les caches totalement associatifs, toute donnée chargée depuis la mémoire peut être placée dans n'importe quelle ligne de cache, sans aucune restriction. Ces caches ont un taux de succès très élevé, quand on les compare aux autres caches. [[File:Cache totalement associatif.png|centre|vignette|upright=2|Cache totalement associatif.]] Concevoir un cache totalement associatif peut se faire de deux grandes manières différentes. La première consiste tout simplement à combiner une mémoire associative avec une mémoire RAM, en ajoutant éventuellement quelques circuits annexes. La mémoire associative mémorise les tags, alors que la mémoire RAM mémorise les données de la ligne de cache, éventuellement avec quelques bits de contrôle. La ligne de cache est stockée à une adresse A dans la mémoire RAM et son tag est stocké à la même adresse, mais dans la mémoire CAM. Ce faisant, quand on envoie le tag à la mémoire CAM, elle renvoie l'adresse de la ligne de cache dans la mémoire RAM. Cette adresse est alors envoyée directement sur le bus d'adresse de la RAM, et la lecture est effectuée automatiquement. Il faut ajouter quelques circuits annexes pour garantir que les écritures se passent correctement dans les deux mémoires, mais rien de bien terrible. [[File:Cache fabriqué avec une mémoire associative et une RAM.png|centre|vignette|upright=3|Cache fabriqué avec une mémoire associative et une RAM]] Il est cependant possible d'optimiser un tel cache, en fusionnant la mémoire CAM et la mémoire RAM, afin d'éliminer des circuits redondants. Pour comprendre pourquoi, rappelons que les mémoires CAM sont composées d'un plan mémoire, d'un paquet de comparateurs et d'un encodeur. Quant à la mémoire RAM, elle est composée d'un décodeur connecté au plan mémoire. En mettant une CAM suivie d'une RAM, on a un encodeur dont l'entrée est envoyée à un décodeur. [[File:Cache totalement associatif naif.png|centre|vignette|upright=3|Cache totalement associatif naif]] Or, le décodeur réalise l'opération inverse de l'encodeur, ce qui fait que mettre les deux composants à la suite ne sert à rien. On peut donc retirer l'encodeur et le décodeur, et envoyer directement les résultats des comparateurs sur les entrées de commande du plan mémoire de la RAM. [[File:Cache totalement associatif optimisé.png|centre|vignette|upright=2|Cache totalement associatif optimisé]] Avec cette méthode, les circuits du cache ressemblent à ce qui illustré ci-dessous. Le tag est envoyé à chaque ligne de cache. Le tag envoyé est alors comparé avec le Tag contenu dans chaque ligne de cache, comme c'est le cas sur les mémoires associatives. Si une ligne de cache matche avec le tag envoyé en entrée, la ligne pour laquelle il y a eu une égalité est alors connectée sur les lignes de bit (''bitlines''). Cela est réalisé par un circuit commandé par le comparateur de la ligne de cache. Il ne reste plus qu'à sélectionner la portion de la ligne de cache qui nous intéresse, grâce à un paquet de multiplexeurs. Cela permet d'effectuer une lecture ou écriture, mais il faut aussi préciser si il y a eu un défaut de cache ou un succès. Un succès de cache a lieu quand au moins des comparaisons est positive, alors que c'est un défaut de cache sinon. En clair, détecter un succès de cache demande juste de connecter une porte OU à plusieurs entrées à tous les comparateurs. [[File:Organisation générale d'un cache totalement associatif.png|centre|vignette|upright=2|Organisation générale d'un cache totalement associatif.]] ===Les caches directement adressés=== Les caches directement adressés peuvent être vus comme un cache totalement associatif auquel on aurait ajouté des restrictions assez drastiques. Plus haut, on a vu qu'un cache totalement adressé est équivalent à la combinaison d'une CAM avec une RAM. La mémoire CAM prend en entrée un Tag et traduit celui-ci en une adresse qui commande la mémoire RAM interne au cache. Dans ce qui suit, l'adresse interne au cache sera appelé l''''indice''' pour éviter toute confusion. [[File:Cache hash table - 2.png|centre|vignette|upright=2|Fonctionnement interne du cache, expliquée sous forme abstraite, en utilisant la notion d'indice interne au cache.]] Les caches directement adressés cherchent à remplacer la mémoire CAM par un circuit combinatoire. Ce circuit traduit le Tag en indice, mais est beaucoup plus simple qu'une mémoire CAM. Mais qui dit circuit plus simple dit circuit plus limité. Un circuit combinatoire n'est pas aussi versatile que ce qui est permis avec une mémoire CAM. En conséquence, une restriction majeure apparait : toute adresse mémoire est associée dans une ligne de cache prédéfinie, toujours la même. L'association entre ligne de cache et adresse mémoire est faite par le circuit combinatoire, et ne peut pas changer. Les concepteurs de caches s'arrangent pour que des adresses consécutives en mémoire RAM occupent des lignes de cache consécutives, par souci de simplicité. Tout se passe comme suit la mémoire RAM était découpés en blocs de la même taille que le cache. La première adresse du bloc est associée à la première ligne de cache (celle d'indice 0), la seconde adresse est associée à la seconde adresse du_ bloc, et ainsi de suite. Le tout est illustré ci-dessous. [[File:Cache adressé directement.png|centre|vignette|upright=2|Cache adressé directement.]] Avec cette contrainte, le circuit de traduction de l'adresse en adresse mémoire pour la RAM interne au cache est drastiquement simplifié, et disparait même. Une partie de l'adresse mémoire sert à indiquer la position de la donnée dans le cache, le reste de l'adresse sert encode le tag et la position de la donnée dans le ligne de cache. [[File:Cache line.png|centre|vignette|upright=2|Adresse d'une ligne de cache sur un cache adressé directement.]] Un cache directement adressé est conçu avec une RAM, un comparateur, et un paquet de multiplexeurs. En général, la mémoire RAM stocke les lignes de caches complète. Il arrive que l'on utilise deux mémoires RAM : une pour les tags et une pour les données, mais cette technique augmente le nombre de circuits et de portes logiques nécessaires, ce qui réduit la capacité du cache. L'index à lire/écrire est envoyé sur l'entrée d'adresse de la RAM, la RAM réagit en mettant la ligne de cache sur sa sortie de donnée. Sur cette sortie, un comparateur compare le tag de la ligne de cache lue avec le tag de l'adresse à lire ou écrire. On saura alors si on doit faire face à un défaut de cache. Ensuite, un multiplexeur récupère la donnée à lire/écrire. [[File:Direct mapped cache - french.png|centre|vignette|upright=2|Cache directement adressé.]] L'accès à un cache directement adressé a l'avantage d'être très rapide vu qu'il suffit de vérifier une seule ligne de cache : celle prédéfinie. Mais ces caches ne sont cependant pas sans défauts. Vu que le cache est plus petit que la mémoire, certaines adresses mémoires se partagent la même ligne de cache. Si le processeur a besoin d’accéder fréquemment à ces adresses, chaque accès à une adresse supprimera l'autre du cache : tout accès à l'ancienne adresse se soldera par un défaut de cache. Ce genre de défauts de cache causés par le fait que deux adresses mémoires ne peuvent utiliser la même ligne de cache s'appelle un '''défaut par conflit''' (''conflict miss''). Les défauts par conflit n'existent pas sur les caches totalement associatifs. En conséquence, le taux de succès des caches directement adressés est assez faible comparé aux autres caches. [[File:Cache Block Basic Conflict.svg|centre|vignette|upright=1.5|Exemple de ''Conflict Miss''.]] ===Les caches associatifs par voie=== Les caches associatifs par voie sont un compromis entre les caches directement adressés et les caches totalement associatifs. Pour simplifier, ces caches sont composés de plusieurs caches directement adressés accessibles en parallèle, chaque cache/RAM étant appelé une '''voie'''. Avec ces caches, toute adresse mémoire en RAM est associée à une ligne de cache dans chaque voie. [[File:Cache associatif par voie.png|centre|vignette|upright=2|Cache associatif par voie.]] Le schéma ci-dessous compare un cache directement adressé et un cache associatif à deux voies. On voit que chaque adresse est associée à une ligne de cache bien précise avec un cache directement dressé, et à deux lignes de cache avec un cache associatif à deux voies. L'adresse sera associée à 4 lignes de cache sur un cache associatif à 4 voies, à 8 lignes pour un cache à 8 voies, etc. L'ensemble des lignes de cache associées à une adresse est appelé un '''ensemble'''. [[File:Cache Fill.svg|centre|vignette|upright=2|Comparaison entre un cache directement adressé et un cache associatif à deux voies.]] Sur ces caches, toute adresse est découpée en trois parties : un tag, un index, et un décalage, comme sur les caches directement adressés. Comme vous pouvez le voir, l'organisation est identique à celle d'un cache totalement associatif, à part que chaque ensemble tag-ligne de cache est remplacé par une mémoire RAM qui en contient plusieurs. [[File:Implémentation d'un cache associatif par voie.png|centre|vignette|upright=2|Implémentation d'un cache associatif par voie.]] Le risque de conflits d'accès au cache est donc réduit sur un cache associatif à plusieurs voies, et il est d'autant plus réduit que le cache a de voies. Par contre, leur conception interne fait qu'ils ont un temps d'accès légèrement élevé que les caches directement adressés. Les caches associatifs par voie ont donc un taux de succès et un temps d'accès intermédiaire, situé entre les caches directement adressés et totalement associatifs. Ils sont une sorte de compromis entre réduction des défaut par conflits d'accès au cache et temps d'accès, et complexité des circuits. ==Les optimisations des caches associatifs par voie== Les caches partiellement associatifs regroupent les caches associatifs par voie et directement adressés, ainsi que leurs variantes. En clair : tous les caches qui ne sont pas totalement associatifs. Ils peuvent être optimisés de nombreuses manières, que ce soit pour gagner en performance ou pour économiser de l’énergie. Dans cette section, nous allons voir quelles sont ces optimisations. ===Les caches pseudo-associatifs=== Les caches adressés par voie contiennent une mémoire SRAM par voie. En théorie, les voies sont accédées en parallèles, en même temps, afin de voir si l'on a un succès de cache ou un défaut. Les '''caches pseudo-associatifs''' sont identiques aux caches associatifs par voie, si ce n'est qu'ils vérifient chaque voie une par une. Ils ont été utilisés sur des processeurs commerciaux, un exemple étant l'IBM 370. Là encore, on perd en performance pour gagner en consommation d'énergie. Le temps d'accès dans le meilleur des cas est plus faible pour les caches pseudo-associatifs, mais le pire des cas teste tous les caches avant de tomber sur le bon. Les performances sont donc réduites. Mais la consommation énergétique est meilleure, vu qu'on ne vérifie pas forcément toutes les voies en parallèle. On teste la première voie, éventuellement la seconde, peut-être la troisième, etc. Mais dans le cas général, on ne teste qu'une partie des voies, pas toutes, ce qui donne un gain en termes d'énergie. L'implémentation de caches de ce genre demande que l'on parcoure les voies une par une, en commençant de la première jusqu'à la dernière. Pour cela, un simple compteur suffit. Suivant la valeur du compteur, la voie associée est activée puis accédée. Toute la complexité revient à ajouter un circuit qui prend la valeur du compteur, et active la voie associée, lance un accès mémoire dessus. Vu que les voies sont chacune des caches ''direct mapped'', il suffit pour cela de geler les entrées d'adresse, soit en les déconnectant, soit en utilisant du ''clock gating'' ou de l'évaluation gardée. Les détails d'implémentation, non-cités ici, varient selon le cache. ===La prédiction de voie=== Pour réduire le temps d'accès des caches pseudo-associatifs, certains chercheurs ont inventé la '''prédiction de voie''', qui consiste à faire des paris sur la prochaine voie accédée. L'idée est d'accéder à la voie qui contient la donnée voulue du premier coup, en lisant celle-ci en priorité. Dans son implémentation la plus simple, le cache reste un cache pseudo-associatif. Lors d'un accès au cache, les voies sont toutes parcoures une par une. Par contre, les voies ne sont donc pas parcourues de la première vers la dernière, mais dans un ordre différent. Cette technique permet de mettre en veille les voies sur lesquels le processeur n'a pas parié, ce qui permet de diminuer la consommation énergétique du processeur. C'est plus efficace que d'aller lire plusieurs données dans des voies différentes et de n'en garder qu'une. L'implémentation est assez simple : il suffit d'ajouter un circuit de prédiction de voie,relié au compteur de voie. Une amélioration de la technique fait fonctionner le cache comme un intermédiaire entre cache pseudo-associatif et associatif par voies. L'idée est de chercher la voie prédite en premier, puis de chercher dans toutes les voies en parallèle en cas de défaut de cache. Au lieu d'attendre que les comparaisons de tags donnent leur résultat, le processeur sélectionne automatiquement une voie et configure les multiplexeurs à l'avance. Si le processeur ne se trompe pas, le processeur accède à la donnée plus tôt que prévu. S'il se trompe, le processeur annule la lecture effectuée en avance et recommence en faisant un accès en parallèle aux autres voies. Le compromis entre performance et consommation d'énergie est alors différent. On économise de l'énergie par rapport à un cache associatif par voie, au prix d'une petite perte de performance (doublement des temps d'accès). Mais par rapport à un cache pseudo-associatif, l'économie d'énergie est bien moindre, au prix d'un gain en performance assez manifeste. Prédire quelle voie sera la bonne est assez simple. En vertu du principe de localité, les accès futurs ont des chances de tomber dans les voies les plus fréquemment utilisées ou dans celle plus récemment utilisée. Il suffit de retenir la voie la plus récemment accédée dans un registre, qui sera utilisée comme prédiction. Pour vérifier que la prédiction est correcte, il suffit de comparer le registre et le résultat obtenu après vérification des tags. Cependant, on peut complexifier l'implémentation pour prendre en compte l'adresse à lire/écrire, l'instruction à l'origine de l'accès mémoire ou tout autre paramètre utile. Par exemple, des instructions différentes ont tendance à aller chercher leurs données dans des ensembles différents et la voie à choisir n'est pas la même. Pour cela, il suffit d'utiliser un cache pour stocker la correspondance instruction - voie. Pour plus de simplicité, la mémoire cache des prédictions est parfois remplacée par une RAM, qui est adressée : * soit par le program counter de l'instruction à l'origine de l'accès (en réalité, seulement quelques bits de poids faible de l'adresse) ; * soit par l'adresse à accéder (là encore, quelques bits de poids faible) ; * soit (pour les modes d'adressage qui utilisent un registre de base et un décalage) par un XOR entre les bits de poids faible de l'adresse de base et le décalage ; * soit par autre chose. ===La mise en veille sélective des voies=== Les caches associatifs ont tendance à utiliser beaucoup d'énergie, même quand on n'y accède pas. Aussi, certains processeurs détectent quand le cache est peu utilisé et en profitent pour mettre en veille les voies inutilisées. Vous vous demandez certainement ce qui se passe quand une donnée à lire/écrire est dans une voie désactivée. La réponse est que le cache détecte cette situation, car elle déclenche un succès de cache. Les ''tags'' ne sont en effet pas désactivés, seules les données sont mises en veille. L'implémentation est plus simple sur les caches qui séparent les tags et les données dans deux RAM différentes. Cette optimisation marche surtout sur les gros caches, qui ont des chances d'avoir une portion significative d’inutilisée (pas assez de données pour les remplir), donc généralement les caches L3/L4. Par exemple, les processeurs d'Intel de microarchitecture Ivy Bridge disposent d'un cache de 8 mébioctets à 16 voies, qu'ils peuvent faire passer à 512 kibioctets si le besoin s'en fait sentir. Quand ces processeurs détectent une faible activité, ils mettent en veille 14 voies et n'en gardent que 2 d'actives. Évidemment, les 14 voies sont vidées avant d'être mises en veille, afin qu'une aucune donnée ne soit perdue. ===Les caches ''skew-associative''=== Vous aurez remarqué que dans une voie, les lignes sont accédées en adressage direct : les défauts par conflit sont possibles sur un cache associatif par voie. Pour éviter cela, certains chercheurs ont créé des '''caches ''skew associative''''' (ou associatifs à biais). Pour faire simple, les index des lignes de cache subissent un petit traitement avant d'être utilisés. Le traitement en question est différent suivant la voie de destination, histoire que deux adresses mémoires avec des index identiques donnent des index différents après traitement. Le traitement en question est souvent une permutation des bits de l'index, qui est différente suivant la voie prise, ou un simple XOR avec un nombre qui dépend de la voie. [[File:Implémentation d'un cache skew associative.jpg|centre|vignette|upright=2|Implémentation d'un cache skew associative.]] ==Les caches splittés (''phased caches'')== Dans cette section, nous allons voir les '''caches splittés''' (''phased caches''), qui sont une variante des caches ''direct-mapped'', dans lequel le cache est accédé en deux étapes consécutives. Il ne s'agit pas des caches pipelinés, que nous verrons dans le chapitre sur les processeurs pipélinés, mais laissons cela à plus tard. Il est possible d'appliquer la même méthode sur un cache associatif par voie, mais il y a des méthodes plus simples, qui permettent là aussi d’accéder au cache en plusieurs étapes consécutives. L'idée est de scinder le cache en deux : une mémoire pour les tags, une autre pour les données de la ligne de cache. Les bits de contrôle peuvent être mis dans l'une ou l'autre SRAM, mais ils sont souvent mis dans la RAM pour les tags. En faisant cela, quelques optimisations deviennent possibles, afin de réduire la consommation énergétique en contrepartie d'une perte de performance. La technique s'implémente différemment pour les caches totalement associatifs et partiellement associatifs. Les caches totalement associatifs splittés sont ceux formés en combinant un cache associatif avec une CAM et une RAM combinée. On envoie l'adresse à lire/écrire à la mémoire associative, elle répond en envoyant une adresse à la mémoire RAM. L'accès se fait donc en deux temps, avec l'adresse dans la RAM comme intermédiaire. Il est possible de séparer physiquement les deux étapes en insérant un registre entre la CAM et la RAM, ce qui permet aussi de pipeliner l'accès. Mais c'est rarement fait en pratique, car le cout en circuit d'une mémoire CAM est trop important. L'équivalent pour un cache totalement associatif optimisé, sans CAM et RAM séparée, est trop gourmande en interconnexions pour être implémentée. Les caches totalement associatifs splittés sont donc très rares, l'auteur ne connait aucun exemple de processeur avec un tel cache. Il existe une technique équivalente pour les caches ''direct-mapped'', mais elle demande une certaine modification du cache. Dans les caches ''direct-mapped'' non-splittés, on trouve une mémoire SRAM dont chaque mot mémoire contient une ligne de cache entière, tag inclus. Dans leurs versions splittés, la SRAM est séparée en deux : une pour les tags, une autre pour les données. Précisons qu'il s'agit bien de deux mémoires SRAM adressables. L'adresse à laquelle accéder est envoyée à la SRAM des tags, puis ensuite à la SRAM des données si besoin. L'idée est d’accéder aux tags pour déterminer s'il y a un succès de cache ou un défaut, et ensuite d'accéder aux données. On n’accède pas aux données en parallèle des tags. Faire cela est évidemment plus lent. En cas de défaut de cache, le temps d'accès est similaire : le tag ne correspond pas, on n'accède pas à la SRAM pour les données. Par contre, vu qu'on n'a pas activé la SRAM pour les données, on économise un peu d'énergie, ce qui réduit la consommation d'énergie. En cas de succès de cache, on accède à la SRAM pour les tags, puis à celle pour les données. Pas d'économie d'énergie à l'horizon, sans compter que le temps d'accès augmente : on accède au cache en deux étapes au lieu de faire les deux accès en parallèle. [[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]] Précisons cependant que ce design peut avoir deux avantages en termes de performance. Premièrement, le temps d'accès au cache est légèrement amélioré en cas de défaut de cache. En effet, la SRAM des tags est assez petite, idem pour celle des données. Leur temps d'accès est donc plus faible que pour une grosse SRAM contenant données et tags. Le gain en temps d'accès est donc un avantage, qui ne se manifeste surtout en cas de défaut de cache. Un autre avantage est que l'accès au cache se pipeline plus facilement, ce qui fait qu'on peut effectuer plusieurs accès simultanés au cache. Mais nous verrons cela dans quelques chapitres. ===L'exemple des processeurs 386 et du contrôleur de cache 82385=== Il est important de noter que la séparation entre tags et RAM peut être telle que les deux ne sont pas sur la même puce de silicium ! Voire que les deux sont séparés du processeur ! C'était le cas quand les mémoires caches ont été introduites sur les processeurs grand public, notamment sur les premiers processeurs Intel. La miniaturisation n'avait pas avancé au point où placer un cache dans le processeur était possible. Sur le processeur 386 d'Intel, le cache était un cache splitté, séparé du processeur. Concrètement, le processeur i386 était couplé à un contrôleur de cache Intel 82385 et une mémoire SRAM. Le 82385 contenait les ''tags'' et les bits de contrôle, la SRAM contenait les données, les lignes de cache. Un point important est que les lignes de cache faisaient seulement 32 bits/4 octets, pas plus ! On était loin des lignes de cache actuelles, faisant 64 octets/512 bits. Mais c'était beaucoup plus pratique, vu que le bus système faisait 32 bits de large, idem pour l'interface avec le processeur. Pour intégrer un cache facultatifs (certains ordinateurs 'en passaient). Le schéma ci-dessous montre comment le cache s'intégrait avec le bus système. Pour le bus de commande, le cache servait d'intermédiaire : il recevait les commandes et et les filtrait suivant les succès/défauts de cache. En cas de succès de cache, les commandes de lecture n'étaient pas envoyées à la mémoire RAM. Les adresses étaient transmises à la fois au cache et au bus système (avec un registre entre le bus système et le processeur). Le bus de donnée était lui connecté à la mémoire SRAM et au processeur, avec des MUX/DEMUX pour faire le choix de la source des lectures. Le 82385 surveillait ce qui se passait sur le bus et répondait à la place de la RAM pour certaines lectures. C'était un intermédiaire assez passif, qui se contenait de répondre aux succès et défauts en lecture. Le seul moyen pour copier une donnée dans le cache était un défaut pour une lecture. Si une écriture fait un défaut de cache, la donnée n'est pas écrite dans le cache. De même, en cas de succès de cache pour une écriture, le cache met à jour sa ligne de cache et propage l'écriture en mémoire RAM. Le cache était donc un cache ''write through''. [[File:Controleur de cache 82385 pour l'Intel 386.png|centre|vignette|upright=2.5|Contrôleur de cache 82385 pour l'Intel 386]] Le 82385 pouvait commander soit un cache ''direct mapped'', soit associatif à deux voies. La différence entre les deux est que le 82385 était relié à une mémoire SRAM avec un cache ''direct mapped'', deux SRAM pour deux voies. Pour avoir un cache associatif à deux voies, le 82385 devrait gérer deux signaux ''chip select'' pour activer chaque SRAM/voie suivant les besoins. Il avait précisément quatre signaux CS : deux par SRAM, un pour les lectures, un pour les écritures. Notons que les SRAM utilisées devaient être des SRAM avec un bus de données de 32 bits, des SRAM 16 ou 8 bits ne fonctionnaient pas. Le 82385 rajoutait 4 sorties, pour masquer chaque octet dans ces 32 bits. Le choix entre les deux était le fait d'une entrée : la mettre à 0 indiquait un cache ''direct mapped'', la mettre à 1 forçait un cache à deux voies. Il gérait aussi les accès mémoire non-cacheable, à savoir des accès mémoire qui ne doivent pas être pris en compte par le cache. Pour cela, il avait une entrée NCA (''Non Cacheable Access'') : le cache était "désactivé" quand cette entrée était à 1. C'est un peu une sorte de ''chip select'' pour le 82385, limitée aux accès mémoire. Cette entrée permettait de programmer des intervalles d'adresse auxquels ne pas répondre, en utilisant des circuits de décodage d'adresse adaptés. Il avait aussi une entrée X16, qui permettait d'identifier les accès soit à un composant 16 bits, soit à une entrée-sortie. De tels accès ne doivent pas être mis en cache, ce qui était garanti par cette entrée. Pour rappel, le processeur utilisait un espace d'adressage séparé pour les entrées-sorties, et utilisait donc un bit IO. Ce bit IO pouvait être utilisé pour déterminer le bit X16, qui inhibe l'action du cache. Cette surveillance du bus permettait de gérer une forme limitée de cohérence des caches par invalidation. Dès que le 82385 détectait une prise de contrôle du bus par autre chose que le processeur, il surveillait les adresses transmises sur le bus. En cas de succès de cache, la ligne de cache associée était invalidée. Au-delà de ca, le 82385 avait une entrée FLUSH, qui ordonnait une invalidation totale du cache. Si cette entrée est mise à 1, toutes les lignes de cache sont invalidées. Les ''tags'' sont marqués comme invalides, mais les lignes de cache elles-mêmes ne sont pas touchées. Le 82385 pouvait être intégré dans un système à deux processeurs, voire plus. Pour cela, chaque processeur avait son propre 82385 et sa SRAM rien qu'à lui. Il n'y avait pas de cache partagé entre les deux processeurs. Par contre, les deux caches étaient reliés au même bus système. Pour qu'ils ne se marchent pas sur les pieds, il y avait des circuits d'arbitrage pour gérer l'accès au bus. Un des deux 82385 était mis en mode maitre, l'autre était en mode esclave. Le 82385 maitre pouvait prendre le contrôle du bus, le 82385 esclave devait demander l'autorisation au premier pour accéder au bus système. ===L'exemple des processeurs Intel de microarchitecture ''Broadwell''=== Un autre exemple est celui du cache L4 des processeurs Broadwell et de quelques processeurs séparés. Ces processeurs ont une organisation en ''chiplet'' où le processeur incorpore plusieurs puces séparées : une puce pour le processeur proprement dit, une puce nommée ''Crystal Well'' pour le cache L4, et une puce IO pour la communication avec la RAM et la carte mère. Le processeur incorporait un cache L4 de 128 mébioctets, composé de mémoire eDRAM, qui était dispersé entre ''Crystal Well'' et les autres puces. Les données du cache L4 étaient dans ''Crystal Well'', alors que les Tags étaient soit dans le processeur lui-même, soit dans la puce IO ! La puce ''Crystal Well'' était une mémoire DRAM adressable tout ce qu'il y a de plus basique, avec cependant quelques optimisations notables. Par exemple, elle avait deux bus séparés pour l'écriture et la lecture. De plus, elle avait une organisation interne avec 128 banques, contre moins d'une dizaine pour la DDR de l'époque et environ 32 banques pour la DDR5 moderne. Elle contenait aussi quelques circuits pour gérer son rôle de mémoire cache, mais rien en ce qui concerne la gestion des tags eux-mêmes. Sur les processeurs de microarchitecture ''Broadwell'', les tags étaient placés dans le CPU et précisément dans le cache L3. À chaque accès mémoire au cache L3, les tags du cache L4 étaient consultés en parallèle. De fait, l'accès au cache L4 était assez rapide, malgré le fait que les données étaient dans une puce à part. Ajoutons à cela que le processeur et ''Crystal Well'' n'avaient pas la même finesse de gravure ni la même technologie de fabrication. Les tags étaient implémentés avec de la SRAM contre la DRAM pour les données, ce qui fait que la consultation des tags était plus rapide que l'accès aux données. Par la suite, dans certains CPU de microarchitecture ''skylake'', les tags ont été déplacés en-dehors du processeur pour finir dans le contrôleur mémoire. En faisant cela, le cache L4 pouvait être utilisé par autre chose que le processeur, et notamment par la carte graphique intégrée au CPU. Avec ''broadwell'', le fait que les tags étaient consultés en cas d'accès au L3 empêchait au GPU intégré de consulter le cache L4. Mais en déplaçant les tags dans le contrôleur mémoire, ce n'est plus le cas vu que la carte graphique a aussi accès au bus mémoire. Par contre, le temps d'accès augmente comparé à la solution précédente. On n'accède pas aux tags du L4 en parallèle du L3 : à la place, il faut consulter les tags du L3, détecter un défaut de cache L3, et ensuite accèder aux tags. ===Les caches RAM-configurables=== Un autre avantage des caches splittés est qu'on peut les modifier pour servir à la fois de mémoire cache, mais aussi de ''local store'', de mémoire RAM de petite taille. Le fonctionnement est assez simple à comprendre. Lors d'un accès au cache, on accède aux tags, puis à la RAM interne au cache. Lors d'un accès au ''local store'', on contourne l'accès au tags et on accède à la RAM interne au cache directement. Il s'agit de la technique du '''cache RAM-configurable''. L'usage de cache RAM-configurable est fréquent sur les cartes graphiques récentes, qui incorporent un ou plusieurs processeurs multicoeurs, dont le cache L1 de données est un cache RAM-configurable. [[File:Hydride cache - local store.png|centre|vignette|upright=2.0|Hydride cache - local store]] ===La compression de cache=== Une autre optimisation permise par les ''phased caches'' est l'implémentation de techniques de '''compression de cache''', qui visent à compresser des lignes de cache. L'intérêt est qu'on peut stocker plus de données dans le cache, à capacité égale. L'inconvénient est qu'on doit compresser/décompresser les lignes de cache, ce qui demande un circuit en plus et allonge les temps d'accès. En effet, le temps mis pour compresser/décompresser une ligne de cache s'ajoute au temps d'accès. Aussi, la compression de cache sert surtout pour les caches de bas niveau dans la hiérarchie mémoire, les gros caches aux temps d'accès assez longs. Une première technique, assez simple à implémenter et peu couteuse en circuit, est celle de la '''compression des lignes de cache nulles'''. Elle compresse uniquement les lignes de cache qui ne contiennent que des zéros. L'idée est qu'on ajoute, dans la mémoire des tags, un bit de contrôle pour chaque ligne de cache appelé le bit ''null''. Il indique si la ligne de cache ne contient que des zéros. Quand on lit une ligne de cache, la mémoire des tags est accédée et on vérifie le bit ''null'' : s'il vaut 1, on n'accède pas à la mémoire cache de données et un multiplexeur envoie un zéro sur le port de lecture. Le bit ''null'' est fixé lors de l'écriture d'une ligne de cache : elle passe dans un comparateur avec zéro relié à la mémoire des tags. La comparaison avec zéro peut se faire en parallèle de l'écriture ou avant (dans ce cas, on n'écrit pas la ligne de cache dans le cache). Les autres techniques de compression de cache permettent de compresser autre chose que des lignes de cache nulles. L'idée est qu'une ligne de cache physique peut par moment mémoriser plusieurs lignes de caches compressées. Par exemple, prenons un cache dont les lignes de cache font 64 octets. Il est possible de compresser deux lignes de cache pour qu'elles fassent chacune 32 octets, et les stocker dans une seule ligne de cache. Les deux lignes de cache auront des tags différents, mais pointeront sur la même ligne de cache physique. Et cela demande d'utiliser un ''phased cache'' dont la mémoire pour les tags est plus grande que la mémoire pour les données. Il n'y a donc plus une bijection entre tags et ligne de cache, mais une relation surjective. Chose qui n'est possible qu'avec un ''phased cache''. De plus, des bits de contrôles associés à chaque ''tag'' indiquent où se trouvent les lignes de cache compressées dans la ligne de cache : est-ce que c'est les 32 octets de poids fort ou de poids faible ? [[File:Compression de cache.png|centre|vignette|upright=2|Compression de cache]] Il ne semble pas que les techniques de compression de cache soient implémentées sur les processeurs modernes. Aucun n'utilise de compression de cache, à ma connaissance. Il faut dire que les techniques connues sont de mauvais compromis : le temps d'accès du cache augmente beaucoup, le cout en circuit pourrait être utilisé pour un cache non-compressé mais plus grand. Et notons que la compression de cache ne marche que si les données peuvent se compresser. Si ce n'est pas le cas, une partie de la mémoire des tags est inutilisée. Une revue de la littérature académique sur la compression de cache est disponible via ce lien, pour les curieux : * [https://inria.hal.science/hal-03285041 Understanding Cache Compression, par Carvalho et Seznec]. ==L'adressage physique ou logique des caches== Le cache utilise les adresses à lire/écrire pour déterminer s'il a une copie de la donnée en son sein. Mais l’interaction entre caches et mémoire virtuelle donne lieu à un petit problème : l'adresse utilisée est-elle une adresse virtuelle/logique ou physique ? La réponse varie suivant le processeur : certains caches utilisent l'adresse virtuelle, tandis que d'autres prennent l'adresse physique. On parle de cache '''virtuellement tagué''' dans le premier cas et de cache '''physiquement tagué''' dans le second. {| |[[File:Cache tagué virtuellement.png|vignette|Cache tagué virtuellement.]] |[[File:Cache tagué physiquement.png|vignette|Cache tagué physiquement.]] |} ===L'accès à un cache physiquement/virtuellement tagué=== La manière d'accéder à un cache dépend de s'il est virtuellement ou physiquement tagué. Il faut utiliser l'adresse virtuelle pour les premiers, physique pour les seconds. Avec un cache virtuellement tagué, l'adresse logique peut être envoyée directement au cache. La MMU ne traduit les adresses que s'il faut accéder à la mémoire RAM. Ces caches sont donc plus rapides. Avec un cache physiquement tagué, le processeur doit traduire l'adresse logique en adresse physique dans la MMU, avant d'accéder au cache. La traduction d'adresse se fait soit en accédant à une table des pages en mémoire RAM, soit en accédant à un cache spécifiquement dédié à accélérer la traduction d'adresse, la TLB (''Translation Lookaside Buffer''). Dans la quasi-totalité des cas, la traduction d'adresse passe par la TLB, ce qui fait qu'elle est raisonnablement rapide. Toujours est-il que chaque accès au cache demande d'accéder à la TLB et de faire la traduction d'adresse avant d'accéder au cache. L'accès est donc plus lent que sur les caches virtuellement tagués, où les accès sont plus directs. [[File:Virtual and Physical addressing.svg|centre|vignette|upright=2|Cache tagué virtuellement versus physiquement tagué.]] ===Les défauts des caches virtuellement tagués=== Les caches physiquement tagués sont moins rapides que les caches virtuellement adressés. Pourtant, les caches virtuellement tagués sont peu fréquents sur les processeurs modernes. Et la raison est assez intéressante : c'est une question d'adresses homonymes et synonymes. ====Les droits d'accès doivent être vérifiés lors d'un accès au cache==== Un premier problème est que la protection mémoire est compliquée avec de tels caches. Rappelons que certaines portions de mémoire sont accessibles seulement en lecture, ou sont interdites en écriture, sont inexécutables, etc. Ces droits d'accès sont gérés par la MMU, qui vérifie pour chaque accès mémoire que l'accès est autorisé. En bypassant la MMU, l'accès au cache virtuellement tagué ne permet pas de faire ces vérifications. Il est possible de charger une donnée en lecture seule dans le cache, mais d'y faire des accès en écriture pour les accès ultérieurs. Les solutions à cela sont multiples. La première consiste à consulter la MMU en parallèle de l'accès au cache. L'accès au cache est alors réalisé de manière spéculative, et est ensuite confirmé/annulé une fois que la MMU a rendu son verdict. Les performances du cache restent alors les mêmes : l'accès à la MMU se fait en parallèle de l'accès au cache, pas avant. Une autre solution est d'ajouter les droits d'accès en question dans la ligne de cache, dans les bits de contrôle situés après le Tag. Chaque accès au cache récupère ces bits de contrôle et vérifie si l'accès est autorisé. L'inconvénient est que les lignes de cache deviennent plus longues, les droits d'accès sont dupliqués entre MMU et cache. Mais si le budget en transistor suit, ce n'est rien d'insurmontable. ====Les adresses homonymes perturbent la gestion du cache==== Pour rappel, une adresse logique homonyme correspond à plusieurs adresses physiques différentes. Elles surviennent quand chaque programme a son propre espace d'adressage. Dans ce cas, une adresse logique correspondra à une adresse physique différente par programme.Une autre manière de voir les choses est qu'il y a en réalité deux adresses homonymes, qui ont la même valeur, mais appartiennent à des espaces d'adressage différentes. Et c'est cette seconde interprétation que nous allons utiliser. Les caches doivent gérer ces adresses homonymes et faire en sorte que la lecture/écriture d'une adresse homonyme se fasse à la bonne adresse physique, dans la bonne ligne de cache. Et autant un cache physiquement tagué n'a aucun problème avec ça, vu qu'il ne gère que des adresses physiques, autant des problèmes surviennent avec les caches virtuellement tagués. Le problème est que les caches virtuellement tagués doivent faire la différence entre deux adresses homonymes de même valeur. Pour corriger ces problèmes, il existe deux grandes méthodes. La première méthode est simple : '''vider les caches''' en changeant de programme. Leur contenu est rapatrié en mémoire RAM, puis les caches sont remis à zéro. Le vidage du cache recopie les lignes de cache ''dirty'' (modifiées) en RAM, puis efface/invalide tout le cache. C'est à cela que servent les instructions ''clean'' et d'invalidation vues plus haut, elles ont été inventées pour cette situation précise. Lorsque le système d'exploitation déclenche une commutation de contexte, à savoir qu'il change le programme en cours d'exécution, le processeur vide tous les caches du processeur. Les interruptions font la même chose, elles vide tous les caches du processeur. Une seconde méthode numérote chaque programme en cours d'exécution, chaque processus. Le numéro attribué est spécifique à chaque processus, ce qui fait qu'il est appelé un '''identifiant de processus CPU'''. Le processeur mémorise l'identifiant du programme en cours d'exécution dans un registre dédié. L'identifiant de processus CPU est utilisé lors des accès mémoire. Chaque ligne de cache contient le numéro de l'espace d'adressage associé, dans son ''tag''. Lors de chaque accès mémoire, l'ID du registre est comparé à l'ID de la ligne de cache accédée, pour vérifier que l'accès mémoire accède à la bonne donnée. Cette méthode n'est pas très économe en termes de transistors. L'usage d'identifiant de processus CPU est clairement meilleure en termes de performance, les commutations de contexte sont plus rapides. Par contre, le budget en transistor est plus important. Un autre défaut de cette méthode est que l'identifiant de processus est généralement codé sur une dizaine de bits, alors que le système d'exploitation utilise des identifiants de processus beaucoup plus larges, de 32 à 64 bits sur les CPU 32/64 bits. L'OS doit gérer la correspondance entre identifiants de processus CPU et ceux de l'OS. Parfois, pour cette raison, les OS n'utilisent pas toujours ce système d'identifiant de processus CPU. ====Les adresses synonymes perturbent aussi la gestion du cache==== La gestion des adresses synonymes est aussi un gros problème sur les caches virtuellement tagués. Pour rappel, il s'agit du cas où des adresses logiques différentes pointent vers la même adresse physique. Typiquement, quand deux programmes se partagent un morceau de mémoire, ce morceau correspondra à des adresses synonymes dans les deux espaces d'adressage. Mais il arrive que l'on ait des adresses synonymes dans le même espace d'adressage, ce n'est pas si rare ! Autant les adresses synonymes ne posent aucun problème avec les caches physiquement tagués, ce n'est pas le cas avec les caches virtuellement adressés. Sur ces caches, deux adresses logiques synonymes vont tomber dans deux lignes de cache différentes. Corriger ce problème demande d'ajouter des circuits annexes pour détecter les adresses synonymes, qui sont vraiment complexes et ont un cout en termes de performance. Aussi, les caches virtuellement tagués sont très peu utilisés sur les processeurs modernes. ===Les caches virtuellement adressés, mais physiquement tagués=== Si les caches physiquement et virtuellement tagués ont des défauts, il existe un intermédiaire qui est un bon compromis entre ces deux extrêmes. Il s'agit des '''caches virtuellement adressés - physiquement tagués''', aussi appelés '''caches pseudo-virtuels'''. Pour comprendre comment ils fonctionnent, précisons que ces caches sont soit des caches ''direct-mapped'', soit des caches associatifs par voie (composés de plusieurs RAM ''direct-mapped'' accédées en parallèle, plusieurs voies). L'accès à ce genre de cache se fait en deux temps : on accède à un ou plusieurs RAM ''direct-mapped'' et on vérifie ensuite les ''Tags'' pour sélectionner la bonne voie. Sur les caches ''direct-mapped'', on n'a qu'une seule RAM ''direct-mapped''. Sur les caches associatifs, on a plusieurs RAM ''direct-mapped'', appelées des voies, qui sont accédées en parallèle. L'accès se fait donc en deux étapes : adresser les RAM ''direct-mapped'' avec un indice, vérifier les ''tags'' avec le reste de l'adresse. Une autre chose à rappeler est que l'adresse logique est composée de deux parties : un numéro de page logique qui indique dans quel page se situe l'adresse, un décalage/''offset'' qui indique la position de l'adresse dans la page. La traduction d'adresse transforme le numéro de page logique en numéro de page physique, mais laisse le décalage intouché. L'idée est d'utiliser le décalage pour adresser les RAM avec le décalage, tandis que le numéro de page sert de ''tag''. Le décalage est découpé en deux lors de l'accès au cache : les bits de poids fort forment l'indice (l'adresse envoyée à la voie), les bits de poids faible donnent la position de l'adresse dans la ligne de cache. L'idée est d'utiliser un numéro de page physique pour les ''tags'', mais d'adresser les voies avec le décalage logique. Les deux servent à des instants différents : vérification des ''tags'' pour l'adresse physique, accès aux voies pour l'adresse logique. Ainsi, le problème des adresses synonymes ou homonymes est résolu par l'utilisation de l'adresse physique pour les tags. Par contre, l'accès au cache est plus rapide, car on utilise l'adresse logique pour la première étape. Le processeur accède à la TLB et récupère l'adresse physique pendant que l'on adresse les voies, les deux sont faits en parallèle, ce qui fait que tout se passe comme si l'accès à la TLB était gratuit. La TLB étant assez rapide comparé au cache, l'adresse physique est disponible quand on doit faire la comparaison avec les ''tags''. [[File:Virtual - Physical - Pseudo Virtual addressing.svg|centre|vignette|upright=2|Adressage pseudo virtuel des caches.]] Il s'agit d'un excellent compromis entre performance et correction des problèmes des adresses synonymes/homonymes. Tous les caches des processeurs haute performance utilisent cette méthode, au moins pour leurs caches L1. Les caches L2 tendent à utiliser des caches physiquement adressés, pour lesquels la latence d'accès est suffisante pour qu'on accède à la TLB en amont. La raison est assez simple à expliquer, elle provient d'une contrainte assez précise sur le calcul de l'indice. La conséquence est qu'un cache ''direct-mapped'' ne peut pas dépasser la taille d'une page, soit 4 kibioctets sur les ordinateurs actuels. Sur les caches associatifs, on peut dépasser cette limite en augmentant le nombre de voies, mais la taille maximale d'une voie reste celle d'une page. Cette contrainte n'est pas trop grave sur les caches de petite taille, dont les caches L1. La plupart d'entre eux ont trouvé un compromis idéal avec moins d'une dizaine de voies par cache, chacun de 4 kibioctets, ce qui donne des caches allant de 16 à 64 kibioctets, soit entre 4 et 16 voies. Par contre, un cache de grande taille doit utiliser un grand nombre de voies, ce qui est peu pratique. Aussi, cette technique de caches pseudo-virtuels n'est pas toujours appliquée sur les caches L2, qui sont physiquement adressés. Il faut dire qu'on accède au cache L2 lors d'un défaut dans le cache L1, et l'adresse physique est disponible à ce moment-là, elle a déjà été récupérée lors de l'accès au cache L1. On peut donc l'utiliser pour adresser le cache L2 sans perte de performance. ==Le remplacement des lignes de cache== Lorsqu'un cache est rempli et qu'on charge une nouvelle donnée dedans, il faut faire de la place pour cette dernière. Dans le cas d'un cache directement adressé, il n'y a rien à faire vu que la ligne de cache à évincer est déterminée lors de la conception du cache. Mais pour les autres caches, la donnée peut aller dans n'importe quelle ligne ou voie. Or, le choix des données à rapatrier en RAM doit être le plus judicieux possible : on doit virer de préférence des données inutiles. Rapatrier une donnée qui sera surement utilisée sous peu est inutile, et il vaudrait mieux supprimer des données qui ne serviront plus ou alors dans longtemps. Il existe différents algorithmes spécialement dédiés à résoudre ce problème efficacement, directement câblés dans les unités de gestion du cache. Certains sont vraiment très complexes, aussi je vais vous présenter quelques algorithmes particulièrement simples. Mais avant de voir ces algorithmes, il faut absolument que je vous parle d'une chose très importante. Quel que soit l'algorithme en question, il choisit la ligne de cache à évincer et recopie son contenu dans la RAM. Ce qui demande d'identifier et de sélectionner une ligne de cache parmi toutes les autres. Pour cela, le circuit de remplacement attribue une adresse chaque ligne de cache ! Vous avez bien vu : chaque ligne de cache est numérotée par une adresse, interne au cache. ===Le remplacement aléatoire=== Premier algorithme : la donnée effacée du cache est choisie au hasard ! C'est contre-intuitif, mais cet algorithme donne des résultats assez honorables, en plus d'utiliser très peu de portes logiques (un générateur de nombres pseudo-aléatoire est un circuit assez simple). Généralement, les défauts de cache sont séparés par un nombre assez important et irrégulier de cycles d'horloge. Dans ces conditions, cette technique donne un bon résultat. ===FIFO : first in, first out=== Avec l'algorithme FIFO, la donnée effacée du cache est la plus ancienne, celle chargée dans le cache avant les autres. Cet algorithme est très simple à implémenter en circuit, concevoir une mémoire de type FIFO n'étant pas très compliqué, comme on l’a vu dans le chapitre dédié à ce type de mémoires. Et on peut dire que dans le cas d'un cache, l'implémentation est encore plus simple et se contente d'un seul registre/compteur. Typiquement, il suffit d'ajouter un registre qui mémorise où se situe la donnée la plus récente. Toute insertion d'une nouvelle donnée se fait à l'adresse suivante, ce qui demande juste d'incrémenter le registre avant d'utiliser son contenu pour l'accès mémoire. [[File:Algorithme FIFO de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme FIFO de remplacement des lignes de cache.]] Cet algorithme possède une petite particularité sur les caches associatifs par voie : en augmentant le nombre d'ensembles, les performances peuvent se dégrader : c'est ce qu'on appelle l''''anomalie de Bélády'''. ===MRU : most recently used=== Avec l'algorithme MRU, la donnée remplacée est celle qui a été utilisée le plus récemment. Cet algorithme s'implémente simplement avec un registre, dans lequel on place le numéro de la dernière ligne de cache utilisée. Cet algorithme de remplacement est très utile quand un programme traverse des tableaux du premier élément jusqu'au dernier : les données du tableau sont rarement réutilisées, rendant le cache inutile. Il est prouvé que dans ces conditions, l'algorithme MRU est optimal. Mais dans toutes les autres conditions, cet algorithme a des performances assez misérables. ===LFU : least frequently used=== Avec l'algorithme LFU, la donnée supprimée est celle qui est utilisée le moins fréquemment. Cet algorithme s'implémente en associant un compteur à chaque ligne de cache, qui est incrémenté à chaque accès mémoire. La ligne la moins récemment utilisée est celle dont le compteur associé a la plus petite valeur. Implémenter cet algorithme prend pas mal de transistors, car il faut rajouter autant de compteurs qu'il y a de lignes de cache, en plus d'un circuit pour comparer les compteurs et d'un encodeur. [[File:Algorithme LFU de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme LFU de remplacement des lignes de cache]] ===LRU : least recently used=== Avec l'algorithme LRU, la donnée remplacée est celle qui a été utilisée le moins récemment. Cet algorithme se base sur le principe de localité temporelle, qui stipule qu'une donnée accédée récemment a de fortes chances d'être réutilisée dans un futur proche. Et inversement, la donnée la moins récemment utilisée du cache est celle qui a le plus de chance de ne servir à rien dans le futur. Autant la supprimer en priorité pour faire de la place à des données potentiellement utiles. Implémenter l'algorithme LRU peut se faire de différentes manières, qui ont pour point commun d'enregistrer les accès au cache pour en déduire la ligne la moins récemment accédée. La manière la plus simple demande d'utiliser un compteur pour chaque ligne de mémoire cache, un peu comme le LFU. La différence avec le LFU est que le compteur n'est pas incrémenté lors d'un accès mémoire. À la place, ce compteur est incrémenté régulièrement, chaque incrémentation ayant lieu en même temps pour tous les compteurs. Quand un bloc est chargé dans le cache, ce compteur est mis à zéro. Quand une ligne de cache doit être remplacée, un circuit va vérifier la valeur de tous les compteurs : la ligne LRU (la moins récemment utilisée), est celle dont le compteur a la valeur la plus haute. Le circuit est composé d'un paquet de comparateurs, et d'un encodeur, comme pour l'agorithme LFU. ===Les approximations du LRU=== Implémenter le LRU demande un nombre de transistors proportionnel au carré du nombre de lignes de cache. Autant dire que le LRU devient impraticable sur de gros caches. Ce qui fait que les processeurs modernes implémentent des variantes du LRU, moins couteuses en transistors, qui donnent un résultat approximativement semblable au LRU. En clair, ils ne sélectionnent pas toujours la ligne de cache la moins récemment utilisée, mais une ligne de cache parmi les moins récemment utilisées. Ce n'est pas un problème si grave que cela car les lignes les moins récemment utilisées ont toutes assez peu de chance d'être utilisées dans le futur. Entre choisir de remplacer une ligne qui a 0,5 % de chances d'être utilisée dans le futur et une autre qui a une chance de seulement 1 %, la différence est négligeable en termes de taux de succès. Mais les gains en termes de circuits ou de temps d'accès au cache de ces algorithmes sont très intéressants. L'algorithme le plus simple consiste à couper le cache (ou chaque voie s'il est associatif) en plusieurs sections. L'algorithme détermine la section la moins récemment utilisée, avant de choisir aléatoirement une ligne de cache dans cette section. Pour implémenter cet algorithme, il nous suffit d'un registre qui mémorise le morceau le moins récemment utilisé, et d'un circuit qui choisit aléatoirement une ligne de cache. Cette technique s'adapte particulièrement bien avec des caches associatifs à voies : il suffit d'utiliser autant de morceaux que de voies. Autre algorithme, un peu plus efficace : le '''pseudo-LRU de type M'''. Cet algorithme attribue un bit à chaque ligne de cache, bit qui sert à indiquer de façon approximative si la ligne de cache associée est une candidate pour un remplacement ou non. Il vaut 1 si la ligne n'est pas une candidate pour un remplacement et zéro sinon. Le bit est mis à 1 lorsque la ligne de cache associée est lue ou écrite. Évidemment, au fil du temps, toutes les lignes du cache finiront par avoir leur bit à 1. Lorsque cela arrive, l'algorithme remet tous les bits à zéro, sauf pour la dernière ligne de cache accédée. L'idée derrière cet algorithme est d'encercler la ligne de cache la moins récemment utilisée au fur et à mesure des accès. L'encerclement commence lorsque l'on remet tous les bits associés aux lignes de cache à 0, sauf pour la ligne accédée en dernier. Au fur et à mesure des accès, l'étau se resserre autour de la ligne de cache la moins récemment utilisée. Après un nombre suffisant d'accès, l'algorithme donne une estimation particulièrement fiable. Et comme les remplacements de lignes de cache sont rares comparés aux accès aux lignes, cet algorithme finit par donner une bonne estimation avant qu'on ait besoin d'effectuer un remplacement. Le dernier algorithme d'approximation, le '''PLURt''', se base sur ce qu'on appelle un arbre de décision. Il a besoin de n − 1 bits pour déterminer la ligne LRU. Ces bits doivent être organisés en arbre, comme illustré plus bas. Chacun de ces bits sert à dire : le LRU est à ma droite ou à ma gauche : il est à gauche si je vaux 0, et à droite si je vaux 1. Trouver le LRU se fait en traversant cet arbre, et en interprétant les bits un par un. Au fur et à mesure des lectures, les bits sont mis à jour dans cet arbre, et pointent plus ou moins bien sur le LRU. La mise à jour des bits s'effectue lors des lectures et écritures : quand une ligne est lue ou écrite, elle n'est pas la ligne LRU. Pour l'indiquer, les bits à 1 qui pointent vers la ligne de cache sont mis à 0 lors de la lecture ou écriture. {| |[[File:Organisation des bits avec l'algorithme PLURt.jpg|vignette|Organisation des bits avec l'algorithme PLURt.]] |[[File:Ligne de cache pointée par les bits de l'algorithme.png|vignette|Ligne de cache pointée par les bits de l'algorithme.]] |} ===LRU amélioré=== L'algorithme LRU, ainsi que ses variantes approximatives, sont très efficaces tant que le programme respecte relativement bien la localité temporelle. Par contre, Le LRU se comporte assez mal dans les circonstances ou la localité temporelle est mauvaise mais où la localité spatiale est respectée, le cas le plus emblématique étant le parcours d'un tableau. Pour résoudre ce problème, des variantes du LRU existent. Une variante très connue, l''''algorithme 2Q''', utilise deux caches : un cache FIFO pour les données accédées une seule fois et un second cache LRU. Évidemment, les données lues une seconde fois sont migrées du cache FIFO vers le cache LRU, ce qui n'est pas très pratique. Les processeurs n'utilisent donc pas cette technique, mais celle-ci est utilisée dans les caches de disque dur. D'autres variantes du LRU combinent plusieurs algorithmes à la fois et vont choisir lequel de ces algorithmes est le plus adapté à la situation. Notre cache pourra ainsi détecter s’il vaut mieux utiliser du MRU, du LRU, ou du LFU suivant la situation. ==Les écritures dans le cache : gestion et optimisations== Les écritures se font à une adresse mémoire bien précise, qui peut ou non être chargée dans le cache. Si la donnée à écrire est chargée dans le cache, elle est modifiée directement dans le cache, mais elle ne l'est pas forcément en mémoire RAM. Suivant le processeur, les écritures sont ou non propagées en mémoire RAM. Il existe deux stratégies d'écritures, appelées respectivement le ''write-back'' et le ''write-through''. Avec un cache ''write-back'', si la donnée à mettre à jour est présente dans le cache, on écrit dans celui-ci sans écrire dans la mémoire RAM. Dans ces conditions, une donnée n'est enregistrée en mémoire que si celle-ci quitte le cache, ce qui évite de nombreuses écritures mémoires inutiles. [[File:Cache write-through.png|centre|vignette|upright=2|Cache write-through.]] Avec les caches '''Write-Through''', toute écriture dans le cache est propagée en RAM. Cette stratégie augmente le nombre d'écritures dans la mémoire RAM, ce qui peut saturer le bus reliant le processeur à la mémoire. Les performances de ces caches sont donc légèrement moins bonnes que pour les caches ''write back''. Par contre, ils sont utiles dans les architectures avec plusieurs processeurs, comme nous le verrons dans les chapitres sur les architectures multiprocesseurs. [[File:Cache write-back.png|centre|vignette|upright=2|Cache write-back.]] ===Les caches ''Write-through''=== Sans optimisation particulière, on ne peut écrire dans un cache ''write-through'' pendant qu'une écriture en RAM a lieu en même temps : cela forcerait à effectuer deux écritures simultanées, en comptant celle imposée par l'écriture dans le cache. Pour éviter cela, certains caches ''write-through'' intègrent un '''tampon d’écriture''', qui sert de file d'attente pour les écritures en RAM. C'est une mémoire FIFO dans laquelle on place temporairement les données à écrire en RAM, où elles attendent en attendant que la RAM soit libre. Grâce à lui, le processeur peut écrire dans un cache même si d'autres écritures sont en attente dans le tampon d'écriture. Par souci d'efficacité, des écritures à la même adresse en attente dans le tampon d’écriture sont fusionnées en une seule. Cela fait un peu de place dans le tampon d’écriture, et lui permet d'accumuler plus d'écritures avant de devoir bloquer le cache. Il est aussi possible de fusionner des écritures à adresses consécutives de la mémoire en une seule écriture en rafales. Dans les deux cas, on parle de '''combinaison d'écriture'''. Mais la technique du tampon d'écriture a cependant un léger défaut qui se manifeste dans une situation bien précise : quand le processeur veut lire une donnée en attente dans le tampon d’écriture. La première manière de gérer cette situation est de mettre en attente la lecture tant que la donnée n'a pas été écrite en mémoire RAM. On peut aussi lire la donnée directement dans le tampon d'écriture, cette optimisation portant le nom de '''''store-to-load forwading'''''. Dans tous les cas, il faut détecter le cas où une lecture accède à une donnée dans le tampon d'écriture. À chaque lecture, l'adresse à lire est envoyée au tampon d'écriture, qui vérifie si une écriture en attente se fait à cette adresse. Pour cela, le tampon d’écriture doit être un cache, dont chaque entrée mémorise une écriture. Chaque ligne de cache contient la donnée à écrire, et le tag de la ligne de cache contient l'adresse où écrire la donnée. Notons que cache d'écriture a une politique de remplacement de type FIFO, le tampon d'écriture non-optimisé étant une mémoire FIFO. ===Les caches ''Write-back''=== Les caches ''write-back'' ont beau avoir des performances supérieures à celles des caches ''write-through'', il existe des optimisations qui permettent d'améliorer leurs performances. Ces optimisations consistent à ajouter des caches spécialisés à côté du cache proprement dit. Ces caches permettent de mémoriser des données qui sont éliminées du cache par les algorithmes de remplacement de ligne cache, sans pour autant faire une écriture en RAM. En suivant la procédure habituelle de remplacement des lignes de cache, on doit rapatrier la ligne en RAM avant d'en charger une nouvelle. On peut améliorer la situation en faisant l'inverse : on charge la nouvelle ligne pendant que l'ancienne donnée est rapatriée en RAM. Ainsi, la nouvelle ligne est disponible plus tôt pour le processeur, diminuant son temps d'attente. Pour implémenter cette technique, on doit mémoriser l'ancienne ligne de cache temporairement dans un '''cache d’éviction''' (ou ''write-back buffer''). [[File:Cache d’éviction.png|centre|vignette|upright=2|Cache d’éviction]] Les caches directement adressés ou associatifs par voie possèdent aussi un tampon d’écriture amélioré. Pour limiter les défauts par conflit de ces caches, des scientifiques ont eu l'idée d'insérer un cache pour stocker les données virées du cache. En faisant ainsi, si une donnée est virée du cache, on peut alors la retrouver dans ce cache spécialisé. Ce cache s'appelle le '''cache de victime'''. Ce cache de victime est géré par un algorithme de suppression des lignes de cache de type FIFO. Petit détail : ce cache utilise un tag légèrement plus long que celui du cache directement adressé au-dessus de lui. L'index de la ligne de cache doit en effet être contenu dans le tag du cache de victime, pour bien distinguer deux adresses différentes, qui iraient dans la même ligne du cache juste au-dessus. [[File:Victim Cache Implementation Example.svg|centre|vignette|upright=1|Cache de victime.]] ===La configuration du fonctionnement du cache=== Sur de nombreux processeurs, il est possible de configurer la mémoire cache pour qu'elle fonctionne soit en mode ''write-back'', soit en mode ''write-through''. Pour cela, les processeurs modernes incorporent des '''registres de configuration du cache'''. Le terme ''registre de configuration du cache'' est assez transparent et indique bien quel est leur rôle. Ils configurent comment le cache est utilisé et permettent notamment de configurer le cache pour dire s'il doit fonctionner en mode ''write-back'' ou ''write-through''. Ils permettent aussi d'activer ou de désactiver la combinaison sur écriture. Les registres en question sont configurés soit par le BIOS, soit par le système d'exploitation. Ce sont des registres protégés, que les applications ne peuvent pas configurer, elles n'en ont pas le droit. Typiquement, ils ne sont accessibles en écriture qu'en mode noyau. Sur les processeurs x86, les registres de configuration du cache sont appelés des '''''Memory type range registers''''' (''MTRRs''). Les MTRRs sont assez nombreux, et il y a notamment une différence entre mode réel et protégé. Si vous vous souvenez des chapitres sur le mode d'adressage et la mémoire virtuelle, vous vous souvenez que les processeurs x86 incorporent plusieurs modes de fonctionnement. En mode réel, le processeur ne peut adresser qu'un mébioctet de RAM, avec un système de segmentation particulier. En mode protégé, le processeur peut adresser toute la mémoire et la segmentation fonctionne différemment, quand elle n'est pas simplement désactivée. Les MTRRs sont séparés en deux : ceux pour le mode réel, ceux pour le mode protégé. Les MTRRs fixes sont ceux qui configurent le cache en mode réel, ils étaient utilisés pour gérer l'accès au BIOS, à la mémoire VGA de la carte graphique, et quelques autres accès aux entrées-sorties basiques gérées nativement par le BIOS. Pour le mode protégé, les processeurs au-delà du 386 incorporent des MTRRs variables, qui servent pour les autres entrées-sorties en général, notamment les périphériques PCI, la mémoire vidéo de la carte graphique, et j'en passe. De nos jours, les registres de configuration du cache sont désuets et cette fonctionnalité est gérée directement par la mémoire virtuelle. La table des pages contient, pour chaque page mémoire, des bits de contrôle qui disent si la page mémoire est cacheable ou non. Le contournement de cache est alors géré par le système de mémoire virtuelle, le cache de TLB et tout ce qui va avec. ===L’allocation sur écriture=== Que faire quand une écriture modifie une donnée qui n'est pas dans le cache ? Doit-on écrire la donnée dans le cache, ou non ? Si la donnée est écrite dans le cache, on dit que le cache fait une '''allocation sur l'écriture''' (ou ''write-allocate''). Certains caches effectuent une telle allocation sur écriture, mais d'autres ne le font pas ou du moins pas systématiquement. L’allocation sur écriture peut se décliner en deux sous-catégories : le '''chargement à la demande''' et l''''écriture immédiate'''. Dans le premier cas, on charge la donnée à modifier dans le cache, et on la remplace avec la donnée écrite. Dans l'écriture immédiate, l'écriture a lieu directement dans le cache et la donnée à modifier n'est pas chargée dans le cache. Évidemment, seule une portion de la ligne de cache contient la donnée écrite (valide), et le reste contient des données invalides. Le cache doit savoir quelles sont les portions du cache qui sont valides : cela demande d'utiliser un ''sector cache''. [[File:Write-back with write-allocation.svg|centre|vignette|upright=2|Cache Write-back avec allocation sur écriture.]] Sans allocation sur écriture, l'écriture est transférée directement aux niveaux de cache inférieurs ou à la mémoire si la donnée à modifier n'est pas dans le cache. Certains caches de ce genre utilisent une petite optimisation : lors de toute écriture, ils supposent que l'écriture donnera un succès de cache. Si c'est le cas, la ligne de cache qui contient la donnée est mise à jour avec la donnée à écrire. Mais si ce n'est pas le cas, la ligne de cache est invalidée, et l'écriture est transférée directement à la mémoire ou aux niveaux de cache inférieurs. [[File:Write-through with no-write-allocation.svg|centre|vignette|upright=2|Cache Write-through sans allocation sur écriture.]] ===La cohérence des caches=== Il arrive parfois que la mémoire d'un ordinateur soit mise à jour, sans que les modifications soient répercutées dans les mémoires cache. Dans ce cas, le cache contient une donnée périmée. Or, un processeur doit toujours éviter de se retrouver avec une donnée périmée et doit toujours avoir la valeur correcte dans ses caches : cela s'appelle la '''cohérence des caches'''. Il est possible de se retrouver avec des valeurs périmées dans le cache sur les ordinateurs avec plusieurs processeurs, ou si un périphérique écrit en RAM, les modifications ne sont pas répercutées automatiquement dans les mémoires cache. Pour résoudre ce problème, on peut interdire de charger dans le cache des données stockées dans les zones de la mémoire dédiées aux périphériques. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires cache. Autre solution : utiliser le fait que les périphériques déclenchent une interruption matérielle pour laisser le contrôleur DMA accéder à la mémoire. Dans ce cas, il suffit de vider les caches à chaque interruption matérielle. Le processeur peut le faire automatiquement, ou fournir des instructions pour. ==Le ''cache bypassing'' : contourner le cache== Dans certaines situations, le cache n'est pas utilisé pour certains accès mémoire. Diverses techniques permettent en effet d'effectuer des accès mémoire qui contournent le cache, qui ne passent pas par le cache. Ils sont utilisés quand l'accès en cache fait que des instructions normales ne fonctionnent pas. Par exemple, de tels accès directs à la RAM sont notamment utilisés pour l'implémentation d'instructions atomiques, une classe d'instructions spécifiques utilisées sur les processeurs multicœurs, dont nous parlerons dans plusieurs chapitres. Mais ils sont aussi utilisés pour l'accès aux périphériques, ce que nous allons voir maintenant. ===Accéder aux périphériques demande de contourner le cache=== Pour rappel, un périphérique (au sens d'entrée-sortie) contient des registres d’interfaçage qui ont une adresse au même titre que les cases mémoire. Un périphérique peut à tout instant modifier ses registres d’interfaçage, ce qui se répercute automatiquement dans l'espace d'adressage, mais rien de tout cela n'est transmis au cache. Si les accès aux périphériques passaient par l'intermédiaire du cache, on aurait droit à des problèmes. On aurait encore une fois droit à des problèmes de cohérence des caches. Le problème est géré différemment suivant que l'on utilise un espace d'adressage séparé ou des entrées-sorties mappées en mémoire. La solution est que les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache. Cela demande d'adapter le cache et le processeur. L'implémentation exacte dépend de comment sont adressés les périphériques. Pour rappel, il y a deux solutions pour adresser les périphériques : soit les périphériques disposent d'un espace d'adressage séparé de celui de la mémoire, soit il y un espace d'adressage unique partagé entre processeur et mémoire. Les deux cas donnent des solutions différentes. Avec un espace d'adressage séparé, l'espace d'adressage des périphériques n'est pas caché : aucun accès dans cet espace d'adressage ne passe par le cache. La mémoire cache n'est utilisée que pour l'espace d'adressage des mémoires, rien d'autre. C'est de loin le cas le plus simple : il suffit de concevoir le processeur pour. Il dispose d'instructions séparées pour les accès aux registres d’interfaçage et à la RAM/ROM, les premières ne passent pas par le cache, les autres si. Avec des entrées-sorties mappées en mémoire, la même solution est utilisée, mais dans une version un peu différente. Là encore, les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache, si on veut qu'ils marchent comme ils le doivent. Cela demande d'adapter le cache et le matériel pour que accès aux périphériques mappés en mémoire contournent le cache. Des adresses, voire des zones entières de la mémoire, sont marquées comme étant non-cachables. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires caches. Là encore, le processeur doit être prévu pour : on doit pouvoir le configurer de manière à marquer certaines zones de la RAM comme non-cacheable. Reste qu'il faut marquer des régions de la RAM comme non-cacheable. Pour cela, on améliore les registres de configuration du cache, vus plus haut, afin qu'ils permettent de configurer certaines portions de la RAM pour préciser qu'elles ne doivent pas être mises en cache, qu'il faut activer le contournement de cache pour celles-ci. ===Contourner le cache pour des raisons de performance=== Il arrive que des données avec une faible localité soient chargées dans le cache inutilement. Or, il vaut mieux que ces données transitent directement entre le processeur et la mémoire, sans passer par l'intermédiaire du cache. Pour cela, le processeur peut fournir des instructions d'accès mémoire qui ne passent pas par le cache, à côté d'instructions normales. De telle instructions sont appelées des '''instructions mémoire non-temporelles'''. Non-temporelle, dans le sens : pas de localité temporelle (c.a.d que les données ne seront pas réutilisées plus tard). Mais il existe aussi des techniques matérielles, où le cache détecte à l'exécution les lectures qui gagnent à contourner le cache. La dernière méthode demande d'identifier les instructions à l'origine des défauts de cache, le processeur accédant directement à la RAM quand une telle instruction est détectée. Si une instruction d'accès mémoire fait trop de défauts de cache, c'est signe qu'elle gagne à contourner le cache. L'idée est de mémoriser, pour chaque instruction d'accès mémoire, un historique de ses défauts de cache. Il existe plusieurs méthodes pour cela, mais toutes demandent d'ajouter de quoi mémoriser l'historique des défauts de cache des instructions. L'historique est mémorisé dans une mémoire appelée la '''table d’historique des défauts de lecture''' (''load miss history table''), qui est souvent un cache. L'historique en question est, dans sa version la plus simple, un compteur de quelques bits incrémenté à chaque succès de cache et décrémenté à chaque défaut de cache, qui indique si l'instruction a en moyenne fait plus de défauts ou de succès de cache. La table associe le ''program counter'' d'une instruction mémoire à cet historique. À la première exécution d'une instruction d'accès mémoire, une entrée de cette table est réservée pour l'instruction. Lors des accès ultérieurs, le processeur récupérer les informations associées et décide s'il faut contourner le cache ou non. ==La hiérarchie mémoire des caches== [[File:Cache Hierarchy.png|vignette|Hiérarchie de caches]] On pourrait croire qu'un seul cache est largement suffisant pour compenser la lenteur de la mémoire. Hélas, les processeurs sont devenus tellement rapides que les caches sont eux-mêmes très lents ! Pour rappel, plus une mémoire peut contenir de données, plus elle est lente. Et les caches ne sont pas épargnés. Si on devait utiliser un seul cache, celui-ci serait très gros et donc trop lent. La situation qu'on cherche à éviter avec la mémoire RAM revient de plus belle. Même problème, même solution : si on a décidé de diviser la mémoire principale en plusieurs mémoires de taille et de vitesse différentes, on peut bien faire la même chose avec la mémoire cache. Depuis environ une vingtaine d'années, un processeur contient plusieurs caches de capacités très différentes : les caches L1, L2 et parfois un cache L3. Certains de ces caches sont petits, mais très rapides : c'est ceux auxquels on va accéder en priorité. Viennent ensuite d'autres caches, de taille variable, mais plus lents. Les processeurs ont donc une hiérarchie de caches qui se fait de plus en plus complexe avec le temps. Cette hiérarchie est composée de plusieurs niveaux de cache, qui vont des niveaux inférieurs proches de la mémoire RAM à des niveaux supérieurs proches du processeur. Plus on monte vers les niveaux supérieurs, plus les caches sont petits et rapides. Un accès mémoire dans une hiérarchie de cache fonctionne comme suit : on commence par vérifier si la donnée recherchée est dans le cache le plus rapide, à savoir le cache L1. Si c'est le cas,n on la charge depuis ce cache directement. Si elle n’y est pas, on vérifie si elle est dans le cache de niveau supérieur, le cache L2. Et rebelote ! Si elle n'y est pas, on vérifie le cache du niveau supérieur. Et on répète cette opération, jusqu’à avoir vérifié tous les caches. Si la donnée n'est dans aucun cache, on doit alors aller chercher la donnée en mémoire. [[File:Hiérarchie de caches.png|centre|vignette|upright=2|Hiérarchie de caches]] Il y a des différences assez notables entre chaque niveau de cache. Par exemple, les différents niveaux de cache n'ont pas forcément les mêmes politiques de remplacement des lignes de cache. Le cache L1 a généralement une politique de remplacement simple, très rapide, mais peu efficace. De même, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Le cache le plus proche de la mémoire est appelé le '''cache de dernier niveau''', ''Last Level Cache'' en anglais. Il a parfois des caractéristiques totalement différentes des autres caches. Par exemple, sur les processeurs multicoeurs, le cache L3 n'est techniquement pas dans le cœur, mais fait partie d'un ensemble de circuits reliés, comme le contrôleur mémoire ou l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc. ===Les caches exclusifs et inclusifs=== Notons que du point de vue de cette vérification, il faut distinguer les caches inclusifs et exclusifs. Avec les caches inclusifs, si une donnée est présente dans un cache, alors elle est présente dans les caches des niveaux inférieurs, ce qui implique l'existence de données en doublon dans plusieurs niveaux de cache. À l'opposé, les caches exclusifs font que toute donnée est présente dans un seul cache, pas les autres. Il existe aussi des caches qui ne sont ni inclusifs, ni exclusifs. Sur ces caches, chaque niveau de cache gère lui-même ses données, sans se préoccuper du contenu des autres caches. Pas besoin de mettre à jour les niveaux de cache antérieurs en cas de mise à jour de son contenu, ou en cas d'éviction d'une ligne de cache. La conception de tels caches est bien plus simple. Dans les '''caches exclusifs''', le contenu d'un cache n'est pas recopié dans le cache de niveau inférieur. Il n'y a pas de donnée en double et on utilise 100 % de la capacité du cache, ce qui améliore le taux de succès. Par contre, le temps d'accès est un peu plus long. La raison est que si une donnée n'est pas dans le cache L1, on doit vérifier l'intégralité du cache L2, puis du cache L3. De plus, assurer qu'une donnée n'est présente que dans un seul cache nécessite aux différents niveaux de caches de communiquer entre eux pour garantir que l'on a pas de copies en trop d'une ligne de cache, ce qui peut prendre du temps. [[File:Caches exclusifs.png|centre|vignette|upright=2|Caches exclusifs]] Dans le cas des '''caches inclusifs''', le contenu d'un cache est recopié dans les caches de niveau inférieur. Par exemple, le cache L1 est recopié dans le cache L2 et éventuellement dans le cache L3. Ce genre de cache a un avantage : le temps d'accès à une donnée est plus faible. La raison est qu'il ne faut pas vérifier tout un cache, mais seulement la partie qui ne contient pas de donnée en doublon. Par exemple, si la donnée voulue n'est pas dans le cache L1, on n'est pas obligé de vérifier la partie du cache L2 qui contient la copie du L1. Ainsi, pas besoin de vérifier certaines portions du cache, ce qui est plus rapide et permet de simplifier les circuits de vérification. En contrepartie, l'inclusion fait que qu'une partie du cache contient des copies inutiles, comme si le cache était plus petit. De plus, maintenir l'inclusion est compliqué et demande des circuits en plus et/ou des échanges de données entre caches. [[File:Caches inclusifs.png|centre|vignette|upright=2|Caches inclusifs]] Maintenir l'inclusion demande de respecter des contraintes assez fortes, ce qui ne se fait pas facilement. Premièrement, toute donnée chargée dans un cache doit aussi l'être dans les caches de niveau inférieur. Ensuite, quand une donnée est présente dans un cache, elle doit être maintenue dans les niveaux de cache inférieurs. De plus, toute donnée effacée d'un cache doit être effacée des niveaux de cache supérieurs : si une donnée quitte le cache L2, elle doit être effacée du L1. Ces trois contraintes posent des problèmes si chaque cache décide du remplacement des lignes de cache en utilisant un algorithme comme LRU, LFU, MRU, ou autre, qui utilise l'historique des accès. En effet, dans ce cas, le cache décide de remplacer les lignes de cache selon l'historique des accès, historique qui varie suivant chaque niveau de cache. Par exemple, une donnée rarement utilisée dans le L2 peut parfaitement être très fréquemment utilisée dans le L1 : la donnée sera alors remplacée dans le L2, mais sera maintenue dans le L1. On observe aussi des problèmes quand il existe plusieurs caches à un seul niveau : chaque cache peut remplacer les lignes de cache d'une manière indépendante des autres caches du même niveau, donnant lieu au même type de problème. Pour maintenir l'inclusion, les caches doivent se transmettre des informations qui permettent de maintenir l'inclusion. Par exemple, les caches de niveaux inférieurs doivent prévenir les niveaux de cache supérieurs quand ils remplacent une ligne de cache. De plus, toute mise à jour dans un cache doit être répercutée dans les niveaux de cache inférieurs et/ou supérieurs. On doit donc transférer des informations de mise à jour entre les différents niveaux de cache. Généralement, le contenu des caches d'instruction n'est pas inclus dans les caches de niveau inférieurs, afin d'éviter que les instructions et les données se marchent sur les pieds. Enfin, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Dans ce cas, l'inclusion est plus difficile à maintenir, pour des raisons assez techniques. ===Les caches eDRAM, sur la carte mère et autres=== D'ordinaire, les mémoires caches sont intégrées au processeur, à savoir que cache et CPU sont dans le même circuit imprimé. Les caches sont donc fabriqués avec de la SRAM, seule forme de mémoire qu'on peut implémenter dans un circuit intégré. Intégrer tous les caches dans le processeur est une solution et efficace. Mais certains processeurs ont procédé autrement. [[File:Cache-on-a-stick module.jpg|vignette|Cache-on-a-stick module]] Des processeurs assez anciens incorporaient un cache L1 dans le processeur, mais plaçaient un cache L2 sur la carte mère. Le cache était clippé sur un connecteur sur la carte mère, un peu comme le sont les barrettes de mémoire. On parlait alors de '''''Cache on a stick''''' (COAST). On aurait pu s'attendre à ce que de tels caches soient en DRAM, vu qu'ils sont placés sur des barrettes de RAM, mais la ressemblance avec la mémoire RAM principale s'arrête là. Le cache était fabriqué en mémoire SRAM, même s'il est en théorie possible de faire de tels caches avec de la DRAM. Les premiers processeurs avec un cache faisaient ainsi, au début des années 90. Il a été introduiot sur les processeurs Motorola, et a été utilisé sur les IBM PC et les Macintosh de l'époque. Les ordinateurs Macintosh utilisaient de tels caches, pour la pluaprt des modèles. Pour ce qui est des PC, les premiers processeurs x86 faisaient pareil, notamment les processeurs Intel. Le 486, le Pentium et le Pentium 2 utilisaient des ''Cache on a stick''. L'avantage est que cela permettait de mettre plus de cache, à une époque où les circuits étaient limités en transistors. De plus, cela permettait au consommateur de choisir quelle quantité de cache il voulait, selon ses finances. Il était possible de laisser le processeur fonctionner soit sans mémoire cache, soit avec un cache de 256 Kibioctets, de 512 Kibioctets, etc. Il était possible d'upgrader le cache si besoin. Pour les CPU Intel, le cache était connecté sur le bus système, au même titre que la mémoire RAM et les entrées-sorties. Il faut dire que les processeurs de l'époque utilisaient un bus système et n'avaient pas de bus mémoire dédié. Mais en théorie, rien n’empêche de connecter le cache sur un bus mémoire dédié. Toujours est-il que les lectures et écritures étaient propagées à la fois dans le cache et la RAM. Les écritures se faisaient dans les deux, systématiquement dans la RAM, mais aussi dans le cache en cas de succès de cache. Les lectures étaient servies soit par le cache en cas de succès de cache, soit par la RAM en cas de défaut de cache. Si le cache répondait en premier, la transaction sur le bus se terminait précocement et l'accès en RAM était abandonné. [[File:Intel486 Иерархия памяти.png|centre|vignette|upright=2.5|Intel486 : le cache était connecté sur le bus système.]] À l'inverse, certains processeurs possédaient un cache fabriqué en mémoire DRAM, et plus précisément avec de la mémoire eDRAM. Le cache n'était pas intégré dans le même circuit imprimé que le processeur, mais profitait d'une architecture en ''chiplet''. Pour rappel, cela veut dire que le processeur est en réalité composé de plusieurs circuits intégré séparés, mais interconnectés et soudés sur un même PCB carré. Avec un cache en eDRAM, le cache avait son propre circuit intégré, séparé du circuit intégré du processeur ou du circuit intégré pour le contrôleur mémoire/IO. Un exemple est celui du cache des processeurs Intel de microarchitecture Broadwell, vus dans ce chapitre dans la section sur les caches splittés. Les tags étaient intégrés dans le circuit intégré du processeur, mais les données étaient mémorisées dans une puce d'eDRAM séparée. La puce eDRAM correspondait en réalité à une DRAM adressable qui servait de DRAM pour les données et mémorisaient les voies du cache. ==Les caches adressés par somme et hashés== Les caches adressés par somme sont optimisés pour incorporer certains calculs d'adresse directement dans le cache lui-même. Pour rappel, certains modes d'adressage impliquent un calcul d'adresse, qui ajoute une constante à une adresse de base. Généralement, l'adresse de base est l'adresse d'un tableau ou d'une structure, et la constante ajoutée indique la position de la donnée dans le tableau/la structure. Les caches hashés et les caches adressés par somme permettent de faire l'addition directement dans la mémoire cache. Voyons d'abord les caches hashés, avant de passer aux caches adressés par somme. Sur les '''caches hashés''', l'addition est remplacée par une autre opération, par exemple des opérations bit à bit du style XOR, AND ou OR, etc. Seulement, utiliser des opérations bit à bit pose un problème : il arrive que deux couples Adresse/décalage donnent le même résultat. Par exemple, le couple Adresse/décalage 11101111/0001 donnera la même adresse que le couple 11110000/0000. Dit autrement, deux adresses censées être différentes (après application du décalage) sont en réalité attribuées à la même ligne de cache. Il est toutefois possible de gérer ces situations, mais cela demande des astuces de haute volée pour faire fonctionner la mémoire cache correctement. Sur les '''caches adressés par somme''', le décodeur est modifié pour se passer de l'addition. Pour comprendre comment, il faut rappeler qu'un décodeur normal est composé de comparateurs, qui vérifient si l'entrée est égale à une constante bien précise. Sur un cache ordinaire, l'addition est faite séparément du décodage des adresses par le cache, dans l'unité de calcul ou dans l'unité de génération d'adresse. [[File:Non sum adressed cache.png|centre|vignette|upright=2|Cache normal.]] Mais les caches adressés par somme modifient le décodeur, qui est alors composé de comparateurs qui testent si la somme adresse + décalage est égale à une constante. [[File:Cache adressé par somme.png|centre|vignette|upright=2|Cache adressé par somme.]] Chaque circuit du décodeur fait le test suivant, avec K une constante qui dépend du circuit : : <math>A + B = K</math> Ce qui est équivalent à faire le test suivant : : <math>A + B - K = 0</math> En complément à deux, on a <math>- K = \overline{K} + 1</math>. En injectant dans l'équation précédente, on a : : <math>A + B + \overline{K} + 1 = 0</math> En réorganisant les termes, on a : : <math>A + B + \overline{K} = - 1</math> Il suffit d'utiliser un additionneur ''carry-save'' pour faire l'addition des trois termes. Rappelons qu'un tel additionneur fournit deux résultats en sortie : une somme calculée sans propager les retenues et les retenues en question. Notons que les retenues sont à décaler d'un cran, vu qu'elles sont censées s'appliquer à la colonne suivante. En notant la somme S et les retenues R, on a: : <math>S + (R << 1) = - 1 </math>, le décalage d'un cran à gauche étant noté <math><< 1</math>. Ensuite, -1 est codé avec un nombre dont tous les bits sont à 1 en complément à un/deux. : <math>S + (R << 1) = 111 \cdots 111111</math> [[File:Sum + retenue add.png|centre|vignette|upright=2|Sum + retenue add]] Un simple raisonnement nous permet de savoir si le résultat est bien -1, sans faire l'addition <math>S + (R << 1)</math>. En effet, on ne peut obtenir -1 que si la somme est l'inverse des retenues : un 0 dans le premier nombre correspond à un 1 dans l'autre, et réciproquement. En clair, on doit avoir <math>\overline{S} = R << 1</math>. Pour vérifier cela, il suffit de faire un simple XOR entre la somme et les retenues décalées d'un cran. On a alors : : <math>S \oplus (R << 1) = 111 \cdots 111111</math> La comparaison avec -1 se fait avec une porte ET à plusieurs entrées. En effet, la porte donnera un 1 seulement si tous les bits d'entrée sont à 1, ce qui est ce qu'on veut tester. Au final, l'additionneur pour l'addition adresse + décalage est remplacé par un additionneur carry-save suivi d'une couche de portes XOR et d'un comparateur avec une constante, ce qui économise de circuits et améliore les performances. [[File:Final circuit of sum addressed cache.png|centre|vignette|upright=2|Cache adressé par somme.]] En prenant en compte que la constante K est justement une constante, certaines entrées de l'additionneur carry-save sont toujours à 0 ou à 1, ce qui permet quelques simplifications à grand coup d’algèbre de Boole. Chaque additionneur complet qui compose l’additionneur carry-save est remplacée par des demi-additionneurs (ou par un circuit similaire). Autant dire que l'on gagne tout de même un petit peu en rapidité, en supprimant une couche de portes logiques. Le circuit de décodage économise aussi des portes logiques, ce qui est appréciable. ==Les caches à accès uniforme et non-uniforme== Intuitivement, le temps d'accès au cache est le même pour toutes les lignes de cache. Il s'agit de cache appelés '''caches à accès uniforme''', sous-entendu à temps d'accès uniforme. Mais sur les caches de grande capacité, il arrive souvent que le temps de propagation des signaux varie fortement suivant la ligne de cache à lire. D'ordinaire, on se cale sur la ligne de cache la plus lente pour caler la fréquence d'horloge du cache, même si on pourrait faire mieux. Cependant, les '''caches à accès non uniforme''' ont une latence différente pour chaque ligne d'un même cache. Certaines lignes de cache sont plus rapides que d'autres. Niveau terminologie, nous allons parler de caches UCA et NUCA : ''Uniform Access Cache'' pour les caches à accès uniforme, ''Non-Uniform Access Cache'' pour les caches à accès non-uniforme. [[File:Caches UCA et NUCA.png|vignette|Caches UCA et NUCA.]] Les caches NUCA et UCA sont souvent composés de plusieurs banques séparées, typiquement une par voie. Sur les caches UCA, les banques sont interconnectées avec le processeur de manière à ce que toutes les interconnexions ont la même longueur pour toutes les banques. Typiquement, les banques sont organisées en carré, avec les interconnexions qui partent du centre, avec une disposition en H, illustrée ci-contre Mais avec les caches NUCA, ce n'est pas le cas. Les interconnexions sont simplifiées et ont des longueurs différentes. Les caches NUCA n'ont pas tous le même genre d'interconnexions, qui dépendent du cache NUCA. En général, les interconnexion forme un réseau avec des sortes de routeurs qui redirigent les données/commandes vers la bonne destination : cache ou processeur. Les banques plus proches du processeur sont accessibles plus rapidement que celles éloignées, même si la différence n'est pas énorme. Les caches NUCA sont généralement associatifs par voie. Les plus simples utilisent une banque par voie pour le cache, ce qui fait que certaines voies répondent plus vite que les autres. La détection des succès de cache est alors plus rapide si la donnée lue/écrite est dans une voie/banque rapide. En théorie, les défauts de cache demandent de vérifier toutes les banques, et se calent donc sur la pire latence. Mais divers caches se débrouillent pour que ce ne soit pas le cas, soit en vérifiant les banquyes unes par une, soit par un mécanisme de recherche plus complexe. Les caches NUCA sont surtout utilisés pour les caches L3 et L4, éventuellement les caches L2. Les caches L1 sont systématiquement des caches UCA, car la latence de l'accès au cache L1 est utilisée par le processeur pour décider quand lancer les instructions. Pour simplifier, le processeur peut démarrer en avance une instruction avant qu'une opérande soit lue dans le cache L1, de manière à ce que la donnée arrive en entrée de l'ALU pile en même temps que l'instruction. Une histoire d'exécution dans le désordre et d'émission anticipée des instructions qu'on détaillera dans une bonne dizaine de chapitres. Toujours est-il que tout est plus simple pour le processeur si le cache L1 a un temps d'accès fixe. Par contre, les caches L3 et L4 sont traités en attendant que les données arrivent, le processeur reprend l'exécution des instructions quand les caches L3 et L4 ont terminé de répondre, pas avant. Avec l'association une banque = une voie, la correspondance ligne de cache → bloc de mémoire qui est statique : on ne peut pas déplacer le contenu d'une ligne de cache dans une autre portion de mémoire plus rapide suivant les besoins. Mais la recherche académique a étudié le cas où la correspondance entre une ligne de cache et une banque varie à l’exécution. Pour nommer cette distinction, on parle de caches S-NUCA (''Static NUCA'') et D-NUCA (''Dynamic NUCA''). Intuitivement, on s'attend à ce que les caches D-NUCA soient plus performants que les caches S-NUCA. Les lignes de cache les plus utilisées peuvent migrer dans une banque rapide, alors que les lignes de cache moins utilisées vont dans une banque éloignée. Les lignes de cache se répartissent dans le cache dynamiquement dans les banques où elles sont le plus adaptées. Mais paradoxalement, le gain des caches D-NUCA est presque nul, voire insignifiant. La raison est que les caches D-NUCA doivent incorporer un système pour déterminer dans quelle banque se situe la donnée pour détecter les succès/défauts de cache, ainsi qu'un système pour migrer les données entre banques. Et ce système augmente le temps d'accès au cache, réduisant à néant l'intérêt d'un cache D-NUCA. Si on économise quelques microsecondes de temps d'accès en passant d'un cache UCA à un cache S-NUCA, ce n'est pas pour les perdre en passant à un D-NUCA. La majorité des caches D-NUCA sont donc en cours de recherche, mais ne sont pas utilisés en pratique. ==La tolérance aux erreurs des caches== Une mémoire cache reste avant tout une mémoire RAM, bien que ce soit de la SRAM. Elle n'est pas parfaite et est donc sujette à des erreurs, qui peuvent inverser un bit ou l'effacer. De telles erreurs sont liées à des rayons cosmiques très énergétiques, à des particules alpha produites par le packaging ou le métal deu circuit intégré, peu importe : l'essentiel est qu'ils inversent parfois un bit. Les mémoires modernes savent se protéger contre de telles erreurs, en utilisant trois moyens. ===Les mémoires caches ECC et à bit de parité=== Le premier moyen est l'usage de codes correcteurs d'erreurs, qui ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Les bits ajoutés dépendent de la donnée mémorisée dans le byte, et servent à détecter une erreur, éventuellement à la corriger. Le cas le plus simple ajoute un simple bit de parité pour chaque byte et se contente de détecter les erreurs dans les corriger. Les autres codes ECC permettent eux de corriger des erreurs, mais ils demandent d'ajouter au moins deux bits par byte, ce qui a un cout en circuit plus élevé. Un simple bit de parité permet de détecter qu'un bit a été inversé, mais ne permet pas de corriger l'erreur. En soi, ce n'est pas un problème. Si une erreur est détectée, on considère que la ligne de cache est invalide. Le cache gère la situation comme un défaut de cache et va chercher la donnée valide en mémoire RAM. Le cout en circuits est donc faible, mais les défauts de cache sont plus nombreux. Les codes ECC sont eux capables de corriger les erreurs, si elles ne modifient pas trop de bits d'un coup. Par contre, ils utilisent deux à trois bits par octet, ce qui a un cout en circuits loin d'être négligeable. Il y a donc un compromis entre défauts de cache et cout en circuits. La gestion de l'ECC est différente suivant le niveau de cache. Généralement, le cache L1 n'utilise pas l'ECC mais se contente d'un simple bit de parité pour éviter la corruption de ses données. Le cache étant petit, les corruptions de données sont assez rares, et les défauts de cache induits faibles. Il est plus important d'utiliser un code de détection d'erreur simple, rapide, qui ne ralentit pas le cache et n'augmente pas sa latence. Si une ligne de cache est corrompue, il a juste à aller lire la ligne depuis le cache L2, ou un niveau de cache inférieur. Du moins, c'est possible sur le cache en question est un cache inclusif et/ou ''write-through''. Par contre, le niveau de cache L2 et ceux en-dessous utilisent presque systématiquement une mémoire SRAM ECC. La raison principale étant que ce sont des caches assez gros, pour lesquels la probabilité d'une erreur est assez élevée. Plus une mémoire a de bits et prend de la place, plus il y a une chance élevée qu'un bit s'inverse. Et vu que les caches L2/L3/L4 sont par nature plus lents et plus gros, ils peuvent se permettre le cout en performance lié à l'ECC, idem pour le cout en circuit. Sans compter qu'en cas d'erreur, ils doivent aller lire la ligne de cache originelle en mémoire RAM, ce qui est très lent ! Mieux vaut corriger l'erreur sur place en utilisant l'ECC. ===L'usage du ''memory scrubbing'' sur les caches=== La plupart des erreurs ne changent qu'un seul bit dans un byte, mais le problème est que ces erreurs s'accumulent. Entre deux accès à une ligne de cache, il se peut que plusieurs erreurs se soient accumulées, ce qui dépasse les capacités de correction de l'ECC. Dans ce cas, il existe une solution appelée le ''memory scrubbing'', qui permet de résoudre le problème au prix d'un certain cout en performance. Pour rappel, l'idée est de vérifier les lignes de caches régulièrement, pour éviter que les erreurs s'accumulent. Par exemple, on peut vérifier chaque ligne de cache toutes les N millisecondes, et corriger une éventuelle erreur lors de cette vérification. En faisant des vérifications régulières, on garantir que les erreurs n'ont pas le temps de s'accumuler, sauf en cas de malchance avec des erreurs très proches dans le temps. Il ne s'agit pas d'un rafraichissement mémoire, car les SRAM ne s'effacent pas), mais ça a un effet similaire. Et évidemment, le ''memory scrubbing'' a un cout en performance. On peut faire une comparaison avec le rafraichissement mémoire : les rafraichissement réguliers réduisent les performances, car cela fait des accès en plus. Des accès qui sont de plus timés à des instants bien précis qui ne sont pas forcément les plus adéquats. Il est possible qu'un rafraichissement ait lieu en même temps qu'un accès mémoire et le rafraichissement a la priorité, ce qui réduit les performances. La même chose arrive avec les vérifications du ''memory scrubbing''. Malgré tout, la technique a été utilisée sur les caches de certains processeurs commerciaux, dont des processeurs AMD Athlon et Athlon 64. Elle est surtout utilisable sur les caches L2/L3, pour lesquels le cout du pseudo-rafraichissement est acceptable. ==Un exemple de cache : le cache d'instruction== La grande majorité des processeurs utilise deux caches L1 séparés : un '''cache d'instructions''' dédié aux instructions, et un autre pour les données. Une telle organisation permet de charger une instruction tout en lisant une donnée en même temps. Notons que seul le cache L1 est ainsi séparé entre cache de données et d'instructions. Le cache d’instruction se situe en théorie entre l'unité de chargement et l'unité de décodage. En effet, ce cache prend en entrée une adresse et fournit une instruction. L'adresse est fournie par le ''program counter'', l'instruction est envoyée dans l'unité de décodage. Le cache se situe donc entre les deux. Le cache de données L1 est connecté au chemin de données, et notamment aux unités de communication avec la mémoire, pas au séquenceur. [[File:Caches L1 et positions dans le processeur.png|centre|vignette|upright=2.5|Caches L1 et positions dans le processeur]] Les deux caches sont reliés au processeur par des bus séparés, l'ensemble ressemble à une architecture Harvard, mais où les caches remplacent les mémoires RAM/ROM. Le cache d'instruction prend la place de la mémoire ROM et le cache de données prend la place de la mémoire RAM. Évidemment, il y a des niveaux de caches en dessous des caches de données/instruction, et ceux-ci contiennent à la fois données et instructions, les deux ne sont pas séparées dans des mémoires/caches séparés. Raison pour laquelle l'ensemble est appelé une '''architecture Harvard modifiée'''. Architecture Harvard, car l'accès aux données et instructions se font par des voies séparées pour le processeur, modifiée car la séparation n'est effective que pour le cache L1 et pas les autres niveaux de cache, et encore moins la RAM. Sur les processeurs modernes, il arrive très souvent que le processeur doive charger une instruction et lire/écrire une donnée en même temps. Et à vrai dire, c'est la règle plus que l'exception. L'usage d'une architecture Harvard modifiée permet cela très facilement : on peut accéder au cache d'instruction via un bus, et au cache de donnée avec l'autre ===Pourquoi scinder le cache L1 en cache d'instruction et de données=== L'usage d'un cache d’instruction séparé du cache de données est à contraster avec l'usage d'un cache L1 multiport unique, capable de mémoriser à la fois instructions et données. Les deux solutions sont possibles ont été utilisées. Les premiers processeurs avaient un cache L1 unique et multiport, mais ce n'est plus le cas sur les processeurs modernes, car les contraintes ne sont pas les mêmes. Le compromis à faire est celui entre deux petits caches rapides et un gros cache plus lent. Pour rappel, plus un cache est petit, plus il est rapide et chauffe moins. Donc au lieu d'utiliser, par exemple, un gros cache lent de 64 Kibioctets, on utilise deux caches de 32 kibioctets, plus rapides. La capacité totale est la même, mais le temps d'accès plus faible. En termes de temps d'accès, la meilleure solution est celle des deux caches simple port. Mais pour ce qui est de l'économie de circuits, c'est moins évident. Entre deux mémoires simple port et une mémoire multiport, la différence en termes de transistors est ambigüe et dépend de la capacité des caches. La différence est surtout notable pour les gros caches, moins pour les petits caches. Il faut aussi tenir compte de la capacité effective. Avec deux caches séparés, la répartition de la capacité du cache L1 est fixée une bonne fois pour toutes. Par exemple, avec un cache d'instruction de 32 KB et un cache de données de 32 KB, impossible d'allouer 40 KB aux données et 20 aux instructions. Alors qu'avec un cache L1 unique de 64 KB, on pourrait le faire sans soucis. La répartition se fait naturellement, en fonction de la politique de remplacement du cache et est proche de l'optimal. C'est là un désavantage des caches d'instructions/données séparés : une capacité effective moindre. Tout cela explique pourquoi le cache L1 est le seul à être ainsi scindé en deux, avec une séparation entre instructions et données : les contraintes au niveau du cache L1 et L2 ne sont pas les mêmes. Pour les caches L1, le temps d'accès est plus important que la capacité, ce qui favorise les caches séparés. Par contre, pour les caches L2/L3/L4, le temps d'accès n'est pas déterminant, alors que la capacité effective et l'économie en circuits sont significatives. ===La connexion des caches L1 avec le cache L2=== Pour les connexions avec le cache L2, tout dépend du processeur. Certains utilisent un cache L2 multiport, qui permet aux deux caches L1 de lire ou écrire dans le cache L2 simultanément. [[File:Cache d'instructions.png|centre|vignette|upright=1.5|Cache d'instructions.]] Si le cache L2 ne gère pas les accès simultanés, il n'y a qu'un seul bus relié aux caches L1 et au cache L2. On doit effectuer un arbitrage pour décider quel cache a la priorité, chose qui est réalisé par un circuit d'arbitrage spécialisé. [[File:Circuit d'arbitrage du cache.png|centre|vignette|upright=1.5|Circuit d'arbitrage du cache.]] Généralement, les caches d'instructions peuvent se permettre d'être plus petits que les caches de données, car les programmes sont souvent plus petits que les données manipulées. Songez que des programmes de quelques mébioctets peuvent parfois remplir la RAM avec plusieurs gibioctets de données. Lancez votre navigateur internet et ouvrez une page web un peu chargée, pour vous en convaincre ! ===Les spécificités du cache d'instruction : lecture seule, bloquant, etc=== Les instructions sont rarement modifiées ou accédées en écritures, contrairement aux données. Et cela permet d'utiliser un cache simplifié pour les instructions. Autant un cache généraliste doit permettre les lectures et écritures depuis le processeur (avec les échanges avec la RAM), autant un cache d'instruction peut se contenter des lectures provenant du CPU et des échanges avec la RAM. Le cache d'instructions est donc très souvent en « lecture seule » : le processeur ne peut pas écrire dedans, mais juste le lire ou charger des instructions dedans. Un cache d'instruction est donc plus simple qu'un cache pour les données : on peut retirer les circuits en charge de l'écriture (mais on doit laisser un port d'écriture pour charger les instructions dedans). Le gain en circuits permet d'utiliser un cache d'instruction plus gros ou au contraire de laisser de la place pour le cache de données. Le gain en termes de capacité compense alors un peu les inconvénients des caches séparés. Par contre, cela complique la gestion du code automodifiant, c'est-à-dire des programmes dont certaines instructions vont aller en modifier d'autres, ce qui sert pour faire de l'optimisation ou est utilisé pour compresser ou cacher un programme (les virus informatiques utilisent beaucoup de genre de procédés). Quand le processeur exécute ce genre de code, il ne peut pas écrire dans ce cache L1 d'instructions, mais doit écrire dans le cache L2 ou en RAM, avant de recharger les instructions modifiées dans le cache L1. Cela qui prend du temps et peut parfois donner lieu à des erreurs si le cache L1 n'est pas mis à jour. Les algorithmes de remplacement des lignes de cache optimaux pour les données ne le sont pas pour les instructions, de même que la taille optimale du cache, la taille des lignes de cache optimale, ou même les algorithmes de préchargement. Par exemple, pour le remplacement des lignes de cache, un simple algorithme LRU est presque optimal pour les instructions, autant il peut donner de mauvaises performances quand on manipule beaucoup de tableaux. Cela justifie d'utiliser des caches spécialisés pour chacune. On peut adapter le cache d'instruction à son contenu, ce qui le rend plus rapide ou plus petit à performance égale. Les caches d'instructions sont généralement des caches bloquants. Il ne servirait à rien de rendre un cache d'instruction non-bloquant, le cout en circuits ne se traduirait pas par une augmentation significative des performances. À l'opposé, les caches de données sont non-bloquants sur les architectures modernes, pour des raisons de performance. Ce qui rend la séparation assez intéressante, les deux caches ayant des besoins différents et des implémentations différentes, cela permet d'optimiser le cout en transistors des caches. ===L'impact du cache d'instruction sur les performances=== Sur les architectures conventionnelles, le cache d'instruction a plus d'impact sur les performances que le cache de données. La raison principale est que les instructions ont une meilleure localité spatiale et temporelle que pour les données. Pour la localité spatiale, les instructions consécutives se suivent en mémoire, alors que rien ne garantit que des données utilisées ensemble soient regroupées en mémoire. Pour localité temporelle, elle est très variable pour les données, mais très courante pour les instructions du fait de l'usage fréquent des boucles et des fonctions. : La présence de branchements atténue la localité temporelle des instruction, sauf que la majorité des branchements sautent à un endroit très proche, seuls les appels de fonction brisent la localité spatiale. La conséquence est qu'il arrive que certains CPU aient un cache L1 d'instruction plus gros que celui pour les données. On parle alors de '''cache L1 asymétriques'''. Un exemple est celui des processeurs AMD de microarchitecture Zen, dont le cache d'instruction était deux fois plus gros que le cache de données. Leur cache d'instruction faisait 64 kibioctets, contre seulement 32 pour le cache de données. D'ailleurs, il existe des processeurs assez extrêmes qui se contentent d'un cache d'instruction unique, sans cache de données. C'est le cas sur les processeurs vectoriels ou les GPU que nous verrons dans les chapitres de fin de ce wikilivres. De tels processeurs sont spécialisés dans la manipulation de tableaux de données, traitement qui a une faible localité temporelle. En conséquence, utiliser un cache de données n'est pas vraiment utile, voire peu être contreproductif, alors qu'un cache d’instruction fonctionne parfaitement. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Compléments sur les mémoires de masse | prevText=Compléments sur les mémoires de masse | next=Le préchargement | nextText=Le préchargement }} </noinclude> mk3d5clwupgkqugfy19hcss8tqoa1cl 765122 765121 2026-04-26T15:33:45Z Mewtow 31375 /* L'exemple des processeurs 386 et du contrôleur de cache 82385 */ 765122 wikitext text/x-wiki Le cache est une mémoire intercalée entre la mémoire et un processeur, plus rarement à l'intérieur d'un périphérique. Il est souvent fabriquée avec de la mémoire SRAM, parfois avec de l'eDRAM. Sans lui, on se croirait à l'âge de pierre tellement nos PC seraient lents ! En effet, la mémoire est très lente comparée au processeur. Le temps mis pour accéder à la mémoire est du temps durant lequel le processeur n'exécute pas d'instruction (sauf cas particuliers impliquant un pipeline). Pour diminuer ce temps d'attente, il a été décidé d'intercaler une mémoire petite mais rapide, entre le processeur et la mémoire. Ainsi, le processeur accède à un cache très rapide plutôt qu'à une RAM beaucoup plus lente. ==L'accès au cache== Le cache contient une copie de certaines données présentes en RAM. La copie présente dans le cache est accessible bien plus rapidement que celle en RAM, vu que le cache est plus rapide. Mais seule une petite partie de ces données sont copiées dans le cache, les autres données devant être lues ou écrites dans la RAM. Toujours est-il que le cache contient une copie des dernières données accédées par le processeur. Une donnée est copiée dans la mémoire cache quand elle est lue ou écrite par le processeur. Le processeur conserve une copie de la donnée dans le cache après son premier accès. Les lectures/écritures suivantes se feront alors directement dans le cache. Évidemment, au fur et à mesure des accès, certaines données anciennes sont éliminées du cache pour faire de la place aux nouveaux entrants, comme nous le verrons plus tard. [[File:Principe d'une mémoire cache.gif|centre|vignette|upright=2|Principe d'une mémoire cache.]] La mémoire cache est invisible pour le programmeur, qui ne peut pas déceler celles-ci dans l'assembleur. Les accès mémoire se font de la même manière avec ou sans le cache. La raison à cela est que le cache intercepte les accès mémoire et y répond s'il en a la capacité. Par exemple, si le cache intercepte une lecture à une adresse et que le contenu de cette adresse est dans le cache, le cache va outrepasser la mémoire RAM et la donnée sera envoyée par le cache au lieu d'être lue en RAM. par contre, si un accès se fait à une adresse pour laquelle le cache n'a pas la donnée, alors l'accès mémoire sera effectué par la RAM de la même manière que si le cache n'était pas là. [[File:Accès au cache.png|centre|vignette|upright=2|Accès au cache]] ===Les succès et défauts de caches=== Tout accès mémoire est intercepté par le cache, qui vérifie si la donnée demandée est présente ou non dans le cache. Si la donnée voulue est présente dans le cache, on a un '''succès de cache''' (''cache hit'') et on accède à la donnée depuis le cache. Sinon, c'est un '''défaut de cache''' (''cache miss'') et on est obligé d’accéder à la RAM. Les défauts de cache peuvent avoir plusieurs origines. Tout ce qu'il faut savoir est que lorsque le processeur accède à une donnée ou une instruction pour la première fois, il la place dans la mémoire cache car elle a de bonnes chances d'être réutilisée prochainement. La raison à cela est qu'un programme a tendance à réutiliser les instructions et données qui ont été accédées dans le passé : c'est le ''principe de localité temporelle''. Bien évidement, cela dépend du programme, de la façon dont celui-ci est programmé et accède à ses données et du traitement qu'il fait, mais c'est souvent vrai en général. La première cause des défauts de cache est liée à la taille du cache. À force de charger des données/instructions dans le cache, le cache fini par être trop petit pour conserver les anciennes données. Le cache doit bien finir par faire de la place en supprimant les anciennes données, qui ont peu de chances d'être réutilisées. Ces anciennes données éliminées du cache peuvent cependant être accédées plus tard. Tout prochain accès à cette donnée mènera à un cache miss. C'est ce qu'on appelle un ''Capacity Cache Miss'', ou encore '''défaut de capacité'''. Les seules solutions pour éviter cela consistent à augmenter la taille du cache ou à optimiser le programme exécuté (voir plus bas). Une autre raison pour un défaut est donc la suivante. Lorsqu'on exécute à une instruction ou qu'on accède à donnée pour la première fois, celle-ci n'a pas encore été chargée dans le cache. Le défaut de cache est inévitable : ce genre de cache miss s'appelle un ''Cold Miss'', ou encore un '''défaut à froid'''. De tels défauts sont presque impossibles à éliminer, sauf à utiliser des techniques de préchargement qui chargent à l'avance des données potentiellement utiles. Ces méthodes de préchargement se basent sur le principe de localité spatiale, à savoir le fait que les programmes ont tendance à accéder à des données proches en mémoire. Pour donner un exemple, les instructions d'un programme sont placées en mémoire dans l’ordre dans lequel on les exécute : la prochaine instruction à exécuter est souvent placée juste après l'instruction en cours (sauf avec les branchements). Quand on accède à une donnée ou une instruction, le cache peut précharger les données adjacentes pour en profiter. Nous parlerons de ces techniques de préchargement dans un chapitre dédié, vers la fin du cours. ===Le fonctionnement du cache, vu du processeur=== Vu du processeur, le cache prend en entrée toutes les informations nécessaires pour effectuer un accès mémoire : des signaux de commande, une adresse et la donnée à écrire si besoin. Tout cela est passé en entrée du cache, celui-ci répondant aux accès mémoire via divers bits de contrôles, que le processeur peut lire à souhait. Le cache fournit aussi la donnée à lire, pour les lectures, sur une sortie, connectée directement au bus mémoire/processeur. Globalement, le cache a une capacité limitée, mais il prend en entrée des adresses complètes. Par exemple, sur un processeur 64 bits, le cache prend en entrée des adresses de 64 bits (sauf si optimisations), même si le cache en question ne fait que quelques mébioctets. Les caches sont souvent des mémoires multiports, surtout sur les processeurs récents. Les caches simple port sont rares, mêmes s'ils existent et ont existé par le passé. les caches double port sont eux plus fréquents, et ont généralement un port d'écriture séparé du port de lecture. Mais les caches récents ont plusieurs ports de lecture/écriture et sont capables de gérer plusieurs accès mémoire simultanés. Les données présentes dans le cache sont (pré)chargées depuis la mémoire, ce qui fait que toute donnée dans le cache est la copie d'une donnée en mémoire RAM. Le cache doit faire la correspondance entre une donnée du cache et l'adresse mémoire correspondante. Du point de vue du fonctionnement, on peut voir le cache comme une sorte de table de correspondance, qui mémorise des données, chacune étant associée à son adresse mémoire. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Cela vaut du point de vue du processeur, le fonctionnement interne du cache étant quelque peu différent selon le cache. Il existe des caches dont le fonctionnement interne est bien celui d'une table de correspondance matérielle, d'autres qui sont beaucoup plus optimisés. [[File:Fonctionnement d'une mémoire associative à correspondance.png|centre|vignette|upright=2|Fonctionnement simplifié d'une mémoire cache : les adresses sont dans la colonne de gauche, les données sont dans la colonne de droite. On voit qu'on envoie l'adresse au cache, que celui-ci répond en renvoyant la donnée associée.]] ==La performance des mémoires caches== L'analyse de la performance des mémoires caches est plus riche pour celle des autres mémoires. Sa performance dépend de beaucoup de paramètres, mais on peut cependant citer les principaux. Les deux premiers sont tout bonnement sa latence et son débit, comme pour n'importe quelle autre mémoire. La latence est plus importante que son débit, car le processeur est généralement plus rapide que le cache et qu'il n'aime pas attendre. Mais le critère le plus important pour un cache est sa capacité à empêcher des accès mémoire, son efficacité. Plus les accès mémoire sont servis par le cache au lieu de la RAM, meilleures seront les performances. Pour résumer, la performance d'un cache est surtout caractérisée par deux métriques : le taux de défaut, qui correspond à l’efficacité du cache, et la latence du cache. ===Le taux de succès/défaut=== Le '''taux de succès''' (hit ratio) est un premier indicateur des performances du cache, mais un indicateur assez imparfait. C'est le pourcentage d'accès mémoire qui ne déclenchent pas de défaut de cache. Plus il est élevé, plus le processeur accède au cache à la place de la RAM et plus le cache est efficace. Certains chercheurs préfèrent utiliser le '''taux de défauts''', à savoir le pourcentage d'accès mémoire qui entraînent un défaut de cache. Plus il est bas, meilleures sont les performances. Le taux de défaut est relié au taux de succès par l'équation <math>T_\text{succes} = 1 - T_\text{defaut}</math>. Par définition, il est égal à : : <math>\text{Taux de défauts de cache} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d’accès mémoires}}</math> Plutôt que de comparer le nombre de défauts/succès de cache au nombre d'accès mémoire, il est aussi possible de diviser le nombre de défauts par le nombre total d'instructions. On obtient alors le '''taux de défauts/succès par instruction''', une autre métrique utile. Par définition, elle est égale à : : <math>\text{Taux de défauts par instruction} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d'instructions}} = \text{Taux de défauts de cache} \times \frac{\text{Nombre d’accès mémoires}}{\text{Nombre d'instructions}}</math> Si certains défauts de cache sont inévitables quel que soit le cache, comme les défauts à froids, mentionnés plus haut, d'autres défauts peuvent être évités en augmentant la capacité du cache. C'est le cas des défauts de capacité qui sont causés par un accès à une donnée qui a été éliminée du cache faute de place. Plus le cache est gros, moins il a de chances d'être rempli, moins il doit rapatrier de données, plus son taux de succès augmente. Mais nous reviendrons sur le lien entre taille du cache et taux de défaut plus bas. Le taux de succès ne dépend pas que du cache, mais aussi de la conception des programmes exécutés. Une bonne utilisation du cache (ainsi que de la mémoire virtuelle) repose sur le programmeur qui doit prendre en compte les principes de localités dès la conception de ses programmes. Par exemple, un programmeur peut parfaitement tenir compte du cache au niveau de son algorithme : on peut citer l'existence des algorithmes ''cache oblivious'', qui sont conçus pour être optimaux quelle que soit la taille du cache. Le programmeur peut aussi choisir ses structures de données de manière à améliorer la localité. Par exemple, un tableau est une structure de donnée respectant le principe de localité spatiale, tandis qu'une liste chaînée ou un arbre n'en sont pas (bien qu'on puisse les implémenter de façon à limiter la casse). D'autres optimisations sont parfois possibles : par exemple, le sens de parcours d'un tableau multidimensionnel peut faire une grosse différence. Cela permet des gains très intéressants pouvant se mesurer avec des nombres à deux ou trois chiffres. Je vous recommande, si vous êtes programmeur, de vous renseigner le plus possible sur les optimisations de code ou algorithmiques qui concernent le cache : il vous suffira de chercher sur Google. Il y a une citation qui résume bien cela, prononcée par un certain Terje Mathisen. Si vous ne le connaissez pas, cet homme est un vieux programmeur (du temps durant lequel on codait encore en assembleur), grand gourou de l’optimisation, qui a notamment travaillé sur le moteur de Quake 3 Arena. {{BlocCitation|Almost all programming can be viewed as an exercise in caching.|auteur=Terje Mathisen}} ===La latence moyenne d'un cache=== Le temps mis pour lire ou écrire une donnée varie en présence d'un cache. Certaines lectures/écritures vont atterrir directement dans le cache (succès) tandis que d'autres devront aller chercher leur contenu en mémoire RAM (défaut de cache). Dans tous les cas, qu'il y ait défaut ou non, le cache sera consulté et mettra un certain temps à répondre, égal au temps de latence du cache. Tous les accès mémoires auront donc une durée au moins égale au temps de latence du cache, qui sera notée <math>T_c</math>. En cas de succès, le cache aura effectué la lecture ou l'écriture, et aucune action supplémentaire n'est requise. Ce qui n'est pas le cas en cas de défaut : le processeur devra aller lire/écrire la donnée en RAM, ce qui prend un temps supplémentaire égal au temps de latence de la mémoire RAM. Un défaut ajoute donc un temps, une pénalité, à l'accès mémoire. Dans ce qui suivra, le temps d'accès à la RAM sera noté <math>T_m</math>. Fort de ces informations, nous pouvons calculer le temps de latence moyen d'un accès mémoire, qui est la somme du temps d'accès au cache (pour tous les accès mémoire), multiplié par le temps lié aux défauts. On a alors : : <math>T = T_c + \text{Taux de défaut} \times T_m</math> On voit que plus le taux de succès est élevé, plus le temps de latence moyen sera bas, et inversement. Ce qui explique l'influence du taux de succès sur les performances du cache, influence assez importante sur les processeurs actuels. De nos jours, le temps que passe le processeur dans les défauts de cache devient de plus en plus un problème au fil du temps, et gérer correctement le cache est une nécessité, particulièrement sur les processeurs multi-cœurs. Il faut dire que la différence de vitesse entre processeur et mémoire est tellement importante que les défauts de cache sont très lents : alors qu'un succès de cache va prendre entre 1 et 5 cycles d'horloge, un cache miss fera plus dans les 400-1000 cycles d'horloge. Tout ce temps sera du temps de perdu que le processeur aura du mal à mitiger. Autant dire que réduire les défauts de cache est beaucoup plus efficace que d'optimiser les calculs effectués par le processeur (erreur courante chez de nombreux programmeurs, notamment débutants). ===L'impact de la taille du cache sur le taux de défaut et la latence=== Il y a un lien entre taille du cache, taux de défaut, débit binaire et latence moyenne. Globalement, plus un cache est gros, plus il est lent. Simple application de la notion de hiérarchie mémoire vue il y a quelques chapitres. Les raisons à cela sont nombreuses, mais nous ne pouvons pas les aborder ici, car il faudrait que nous sachions comment fonctionne un cache et ce qu'il y a à l'intérieur, ce qui sera vu dans la suite du chapitre. Toujours est-il que la latence moyenne d'un cache assez gros est assez importante. De même, le débit binaire d'un cache diminue avec sa taille, mais dans une moindre mesure. Les petits caches ont donc un gros débit binaire et une faible latence, alors que c'est l'inverse pour les gros caches. Une grande capacité de cache améliore le taux de succès, mais cela se fait au détriment de son temps de latence et de son débit, ce qui fait qu'il y a un compromis assez difficile à trouver entre taille du cache, latence et débit. Il peut arriver qu'augmenter la taille du cache augmente son temps d'accès au point d’entraîner une baisse de performance. Par exemple, les processeurs Nehalem d'Intel ont vus leurs performances dans certains jeux vidéos baisser de 2 à 3 %, malgré de nombreuses améliorations architecturales, parce que la latence du cache L1 avait augmentée de 2 cycles d'horloge. Pour avoir une petite idée du compromis à faire, regardons la relation entre taille du cache et taux de défaut. Il existe une relation approximative entre ces deux variables, appelée la '''loi de puissance des défauts de cache'''. Elle donne le nombre total de défaut de cache en fonction de la taille du cache et de deux autres paramètres. Voici cette loi : : <math>\text{Taux de défauts de cache} \approx K \times \text{Taille du cache}^{- \alpha }</math>, avec <math>K</math> et <math>\alpha</math> deux coefficients qui dépendent du programme exécuté. Le coefficient <math>\alpha</math> est généralement compris entre 0.3 et 0.7, guère plus, et varie suivant le programme exécuté. Précisons que cette loi ne marche que si le cache est assez petit par rapport aux données à utiliser. Pour un cache assez gros et des données très petites, la relation précédente est mise en défaut. Pour s'en rendre compte, il suffit d'étudier le cas extrême où toutes les données nécessaires tiennent dans le cache. Dans ce cas, il n'y a qu'un nombre fixe de défauts de cache : autant qu'il faut charger de données dans le cache. Le nombre de défauts de cache observé dans cette situation n'est autre que le coefficient <math>K</math> de la situation précédente, mais il n'y a aucune dépendance entre taux de défaut et taille du cache. L'origine de cette relation s'explique quand on regarde combien de fois chaque donnée est réutilisée lors de l’exécution d'un programme. La plupart des données finissent par être ré-accédées à un moment ou un autre et il se passe un certain temps entre deux accès à une même donnée. Sur la plupart des programmes, les observations montrent que beaucoup de réutilisations de données se font après un temps très court et qu'inversement, peu de ré-accès se font après un temps inter-accès long. Si on compte le nombre de réutilisation qui ont un temps inter-accès bien précis, on retrouve une loi de puissance identique à celle vue précédemment : : <math>\text{Nombre de réaccès avec un temps inter-accès égal à t} \approx K \times t^{- \beta}</math>, avec t le temps moyen entre deux réutilisations. Le coefficient <math>\beta</math> est ici compris entre 1.7 et 1.3. De manière générale, les coefficients <math>\alpha</math> et <math>\beta</math> sont reliés par la relation <math>\alpha = 1 - \beta</math>, ce qui montre qu'il y a un lien entre les deux relations. Précisons cependant que la loi de puissance précédente ne vaut pas pour tous les programmes informatiques, mais seulement pour la plupart d’entre eux. Il n'est pas rare de trouver quelques programmes pour lesquels les accès aux données sont relativement prédictibles et où une bonne optimisation du code fait que la loi de puissance précédente n'est pas valide. La loi de puissance des défauts de cache peut se démontrer à partir de la relation précédente, sous certaines hypothèses. Si un suppose que le cache est assez petit par rapport aux données, alors les deux relations sont équivalentes. L'idée qui se cache derrière la démonstration est que si le temps entre deux accès à une donnée est trop long, alors la donnée accédée aura plus de chance d'être rapatriée en RAM, ce qui cause un défaut de cache. La chance de rapatriement dépend de la taille du cache, un cache plus gros peut conserver plus de données et a donc un temps avant rapatriement plus long. ==Les lignes de cache et leurs tags== Du point de vue du processeur, les lectures et écritures se font mot mémoire par mot mémoire. Un processeur avec des entiers de 64 bits recoit des données de 64 bits de la part du cache, et y écrit des mots de 64 bits. Mais quand on regarde comment sont stockées les données à l'intérieur du cache, les choses sont différentes. ===Les lignes de cache=== Les données sont mémorisées dans le cache par blocs de plusieurs bytes, d'environ 64 à 256 octets chacun, qui portent le nom de '''lignes de cache'''. Les lignes de cache sont l'unité de stockage que l'on trouve à l'intérieur du cache, mais elles servent aussi d'unité de transaction avec la mémoire RAM. Sur les caches actuels, on transfère les données entre le cache et la RAM ligne de cache par ligne de cache, dans la limite de la taille du bus mémoire. Mais d'autres caches plus anciens permettaient de faire des transferts plus fins. C’est-à-dire qu'on pouvait mettre à jour quelques octets dans une ligne de cache sans avoir à la recopier intégralement depuis ou dans la mémoire RAM. En théorie, on pourrait imaginer des caches où les données sont stockées différemment, où l'unité serait le mot mémoire, par exemple. Par exemple, sur un processeur 64 bits, on aurait une ligne de cache de 64 bits. Cela aurait l'avantage de la simplicité : les transferts entre le processeur et la mémoire serait de même taille, l'intérieur du cache ressemblerait à son interface montrée au processeur. Mais cela aurait quelques défauts qui sont compensés par l'organisation en lignes de cache de grande taille. Le premier avantage des lignes de cache est lié à la localité spatiale, la tendance qu'on les programmes à accéder à des données proches les unes des autres. Des accès mémoires consécutifs ont tendance à se faire à des adresses proches, qui ont de bonnes chances d'être dans la même ligne de cache. Et des accès consécutifs à une même ligne de cache sont plus rapides que des accès à deux lignes distinctes. Une autre raison est tout simplement que cela simplifie considérablement la circuiterie du cache. Pour une capacité identique, il vaut mieux avoir peu de lignes de cache assez grosses, que beaucoup de petites lignes de cache. La raison est que les circuits du cache, comme le décodeur, l'encodeur et autres, ont moins de sorties et sont donc plus simples. ===L'alignement des lignes de cache=== Les lignes de cache sont des blocs de plusieurs dizaines à centaines de bytes, dont la taille est presque toujours une puissance de deux. De plus, les lignes de cache sont alignées en mémoire. Nous avions déjà abordé la notion d'alignement mémoire dans un chapitre précédent, mais le concept d'alignement des lignes de cache est quelque peu différent. Quand nous avions parlé d'alignement auparavant, il s'agissait de l'alignement des données manipulées par le processeur, qui faisait partie du jeu d'instruction du processeur. Ici, nous parlons d'un alignement totalement différent, invisible pour le programmeur, sans lien avec le jeu d’instruction. Voyons de quoi il retourne. Concrètement, cela veut dire que du point de vue du cache, la RAM est découpée en blocs qui font la même taille qu'une ligne de cache, aux positions prédéterminées, sans recouvrement entre les blocs. Par exemple, pour un cache dont les lignes de cache font 256 octets, le premier bloc est à l'adresse 0, le second est 256 octets plus loin, c'est à dire à l'adresse 256, le troisième à l'adresse 512, la quatrième à l'adresse 768, etc. Une ligne de cache de 256 octets contiendra une donnée provenant d'un bloc de RAM de 256 octets, dont l'adresse est systématiquement un multiple de 256. Il n'est pas possible qu'une ligne de cache contienne un bloc de 256 octets dont l'adresse du premier octet serait l'adresse 64, ou l'adresse 32, par exemple. En clair, les adresses de ces blocs sont des multiples de la taille de la ligne de cache, de la taille des blocs. Cela rappelle les contraintes d'alignement vues dans le chapitre "Le modèle mémoire : alignement et boutisme", mais appliquées aux lignes de cache. L'alignement des lignes de cache a des conséquences pratiques pour la conception des caches. Notons qu'il est en théorie possible d'avoir des caches dont les lignes de cache ne sont pas alignées, mais cela poserait des problèmes majeurs. Il serait en effet possible qu'une donnée soit présente dans deux lignes de cache à la fois. Par exemple, prenons le cas où une ligne de cache de 256 commence à l'adresse 64 et une autre ligne de cache commence à l'adresse 0. L'adresse 128 serait dans les deux lignes de cache ! Et cela poserait des problèmes lors des lectures, mais encore plus lors des écritures. C'est pour éviter ce genre de problèmes que les lignes de cache sont alignées avec la mémoire RAM dans tous les caches existants. L'alignement des lignes de cache est une chose que les programmeurs doivent parfois prendre en compte quand ils écrivent du code ultra-optimisé, destiné à des programmes demandant des performances extrêmes. Il arrive que les contraintes d'alignement posent des problèmes. Nous avions vu dans le chapitre sur le boutisme et l'alignement qu'il valait mieux gérer l'alignement des variables des structures de données, pour éviter les accès non-alignés avec le bus mémoire. La même chose est possible, mais pour l'alignement avec des lignes de cache. Typiquement, l'idéal est que, pour une structure de donnée, on puisse en mettre un nombre entier dans une ligne de cache. Ou alors, si la structure est vraiment grande, que celle-ci occupe un nombre entier de lignes de cache. Si ce n'est pas le cas, il y a un risque d'accès non-alignés, c'est à dire qu'une structure se retrouve à cheval sur deux lignes de cache, avec les défauts que cela implique. ===Le tag d'une ligne de cache=== Plus haut, nous avions dit que le cache mémorise, pour chaque ligne de cache, l'adresse RAM associée. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Mais du fait de l'organisation du cache en lignes de cache de grande taille, qui sont de plus alignées en mémoire, il faut nuancer cette affirmation. Le cache ne mémorise pas la totalité de l'adresse, ce qui serait inutile. L'alignement des lignes de cache en RAM fait que les bits de poids faible de l'adresse ne sont pas à prendre en compte pour l'association adresse-ligne de cache. Dans ces conditions, on mémorise seulement la partie utile de l'adresse mémoire correspondante, qui forme ce qu'on appelle le '''tag'''. Le reste de l'adresse indique quelle est la position de la donnée dans la ligne de cache. Par exemple, prenons le cas où le processeur gère des nombres entiers de 64 bits (8 octets) et des lignes de cache de 128 octets : chaque ligne de cache contient donc 16 entiers. Si le processeur veut lire ou écrire un entier bien précis, il doit préciser sa place dans la ligne de cache. Et ce sont les bits de l'adresse mémoire non-inclus dans le cache qui permettent de faire ça. En clair, une adresse mémoire à lire/écrire est interprété par le cache comme la concaténation d'un tag et de la position de la donnée dans la ligne de cache correspondante. [[File:Adressage d'un cache totalement associatif.png|centre|vignette|upright=2|Adressage d'un cache totalement associatif]] Le cache est donc une grande table de correspondance entre tags et lignes de cache. Lors d'un accès mémoire, le cache extrait le tag de l'adresse à lire ou écrire, et le compare avec les tags de chaque ligne de cache. Si une ligne contient ce tag, alors c'est que cette ligne correspond à l'adresse, et c'est un défaut de cache sinon. Lors d'un succès de cache, la ligne de cache est lue depuis le cache et envoyée à un multiplexeur qui sélectionne la donnée à lire dans la ligne de cache. Le fonctionnement est similaire pour une écriture : la donnée à écrire passe dans un démultiplexeur, qui envoie la donnée au bon endroit dans la ligne de cache sélectionnée. [[File:Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.png|centre|vignette|upright=2|Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.]] ===Le contenu d'une ligne de cache=== Dans ce qui va suivre, nous allons considérer que chaque ligne de cache mémorise son tag, les données de la ligne de cache proprement dit, et quelques bits de contrôle annexes qui varient suivant le cache considéré. [[File:Tag d'une ligne de cache.png|centre|vignette|upright=2|Tag d'une ligne de cache.]] Les caches modernes incluent de nombreux bits de contrôle, mais deux d'entre eux sont communs à presque tous les caches modernes : le bit ''Dirty'' et le bit ''Valid''. Le '''bit ''Valid''''' indique si la ligne de cache contient des données valides ou non. Si le bit ''Valid'' est à 0, la ligne de cache est en état valide, à savoir qu'elle contient des données et n'est pas vide. Par contre, si ce bit est à 1, la ligne de cache est invalide et son contenu ne peut pas être lu ou écrit. L'utilité de ce bit est qu'il permet d'effacer une ligne de cache très rapidement : il suffit de mettre ce bit à 0. Il existe des situations où le cache doit être effacé, on dit alors qu'il est invalidé. Une section de ce chapitre sera dédié à l'invalidation du cache. Le '''bit ''Dirty''''' indique qu'une ligne de cache a été modifiée. Par modifiée, on veut dire que le processeur a écrit dedans, qu'il a modifié la ligne de cache. Mais attention : si la donnée a été modifiée dans le cache, la modification n'est pas forcément propagée en mémoire RAM. Le bit ''dirty'' indique si c'est le cas, si l'écriture a été propagée en mémoire RAM. Il précise que la ligne de cache contient des données modifiées, alors que la RAM a des données initiales non-modifiées. Une ligne de cache avec un bit ''dirty'' à 1 est dite ''dirty'', par métonymie. Nous verrons cela en détail dans la section sur les caches ''write-back'' et ''write-through''. Les caches modernes ajoutent des '''bits de détection/correction d'erreur''' dans les bits de contrôle. Pour rappel, les codes de détection/correction d'erreur permettent de se prémunir contre des erreurs matérielles, qui corrompent les données stockées dans une mémoire, ici une mémoire cache. Ils ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Nous reviendrons dessus dans une section ultérieur de ce chapitre. Sur certains caches assez anciens, on pouvait transférer les lignes de caches morceaux par morceaux. Ces caches avaient des lignes de cache divisées en sous-secteurs, ces sous-secteurs étant des morceaux de ligne de cache qu'on pouvait charger indépendamment les uns des autres (mais qui sont consécutifs en RAM). Chaque secteur avait ses propres bits de contrôle, mais le tag était commun à tous les secteurs. [[File:Cache à secteurs.png|centre|vignette|upright=2.5|Cache à secteurs.]] : Dans ce qui va suivre, le terme "ligne de cache" désignera soit un bloc de données copiées depuis la RAM d'une taille de 64/128/256/... octets, soit la concaténation de ces données avec le tag et des bits de contrôle. Les deux définitions ne sont pas équivalentes, mais l'usage a entériné cet abus de langage. Et il faut avouer que cela rend les explications du chapitre plus simples. ==Les instructions de contrôle du cache== Plus haut, nous avions dit que le cache est totalement transparent du point de vue du programmeur. Le cache contient des copies de données en RAM, le programmeur n'a rien à faire pour utiliser le cache correctement. Mais la réalité est que pour des raisons diverses, des processeurs incorporent des '''instructions de contrôle du cache'''. Il s'agit d’instructions qui agissent sur le contenu du cache. Elles existent pour des raisons diverses qu'on détaillera plus bas, mais il s'agit globalement d'une question de performances ou de nécessité pour le système d'exploitation. ===Les instructions de préchargement=== La première instruction de contrôle du cache est une '''instruction de préchargement''', qui demande à charger un bloc de données dans le cache. Elle prend en opérande une adresse mémoire, et le contenu de cette adresse est chargé dans une ligne de cache. Bien sûr, des contraintes d'alignement sont à prendre en compte : on charge un bloc de la même taille qu'une ligne de cache, aligné en mémoire sur la taille du bloc, qui contient l'adresse. L'instruction de préchargement n'est utile que si l'instruction est exécutée bien avant que la donnée ne soit utilisée/lue/écrite. Cela permet de charger une donnée dans le cache à l'avance, d'où le nom de préchargement donné à cette technique. Mais les processeurs modernes gérent des techniques de préchargement automatique, qui ne requièrent pas d'instructions de préchargement. Le préchargement automatique et les instructions de préchargement sont deux solutions complémentaires, mais qui peuvent se marcher sur les pieds. Nous en reparlerons dans le prochain chapitre, qui sera dédié au préchargement automatique. Il faut noter que les instructions de préchargement peuvent être ignorées par le processeur. Sous certaines conditions, le processeur peut décider que l'instruction de préchargement ne sera pas exécutée. Par exemple, il ne va pas précharger une donnée déjà présente dans le cache. Ou encore, si le bus mémoire est occupé, il ne va pas exécuter le préchargement, par manque de ressources matérielles. ===Les instructions d'invalidation et de ''flush''=== Les instructions ''flush'' regroupent deux types d'instructions qui sont souvent utilisées en même temps. Il s'agit des instructions d'invalidation et de nettoyage (''clean''). Les deux termes proviennent de la terminologie ARM, il n'y a pas de terminologie standardisé pour les noms de ces instructions. Dans les grandes lignes, elles permettent de vider le cache, à savoir de rapatrier son contenu en RAM et de réinitialiser le cache à zéro. Elles sont utilisées par le système d'exploitation lors des commutations de contexte, à savoir quand on passe d'un programme à un autre. Elles sont aussi utilisées lors des appels systèmes et routines d'interruption/exception. L'idée est de vider le cache avant d'exécuter un nouveau programme ou une nouvelle routine. Le nouveau programme aura accès à un cache tout propre, les données de l'ancien programme auront été retirée du cache. Les '''instructions ''clean''''' recopient le contenu de la ligne de cache en RAM. Elles forcent la recopie immédiatement de la ligne de cache en mémoire RAM. Pour faire leur travail, elle vérifient si la ligne de cache a été modifiée, avant de la recopier en RAM. Et pour cela, ils vérifient le bit de contrôle ''dirty'', qui est mis à 1 après une première écriture. Si ce bit est à 0, alors pas besoin de recopier la ligne de cache : elle n'a pas été modifiée, la RAM a déjà la bonne copie. Mais s'il est à 1, le cache et la RAM n'ont pas le même contenu, la recopie s'exécute. Les '''instructions d'invalidation''' permettent d'invalider une ligne de cache, à savoir d'effacer son contenu. Nous verrons à quoi servent ces instructions dans la section sur les changement de processus. Invalider une ligne de cache est une opération optimisée : le cache n'est en réalité pas réellement effacé. À la place, le bit ''Valid'' de chaque ligne de cache est juste mis à 0. Il faut noter que l'invalidation efface les lignes de cache sans se préoccuper de leur contenu. Elle se moque qu'une ligne de cache contienne une donnée modifiée, ''dirty'' ou quoique ce soit : la ligne de cache est effacée, point. Il est possible d'invalider une ligne de cache en fournissant une adresse mémoire, mais il est aussi possible d'invalider le cache tout entier. Le choix entre les deux dépend du mode d'adressage de l'instruction d'invalidation. Parfois, il existe une instruction séparée pour invalider tout le cache, et une autre pour invalider une ligne de cache bien précise. Des instructions séparées sont parfois disponibles pour invalider les caches de données et d'instructions, parfois aussi la TLB (un cache qu'on verra dans quelques chapitres). Il est possible de n'invalider que le cache L1, voire le cache L2. Il faut noter que l'invalidation efface tout le cache, mais ne se préoccupe pas de vérifier si les données ont été modifiées dans le cache. Pour certains caches, comme le cache d'instruction, ce n'est pas un problème, vu qu'il est en "lecture seule". Mais pour les caches de données, les données modifiées sont perdues en cas d'invalidation. Heureusement, il existe des instructions d'invalidation qui fusionnent une instruction ''clean'' et une instruction d'invalidation. Il s'agit d''''instructions d'invalidation spéciales'''. ===Les instructions d'optimisation : instructions non-temporelles et écritures optimisées=== Les '''instructions mémoire non-temporelles''' contournent complètement le cache. Par exemple, une lecture peut lire une donnée, mais celle-ci ne sera pas chargée dans le cache, elle passe directement de la RAM vers les registres. Une section entière de ce chapitre sera dédiée au contournement du cache, à savoir aux situations où les accès mémoire doivent passer directement du processeur à la RAM sans passer par le cache. D'autres instructions assez rares incorporent des indications pour le cache. Par exemple, l'instruction ''load last'' des processeurs POWER PC implique que la donnée ne sera utilisée qu'une seule fois. Elle est donc chargée dans le cache, mais la ligne de cache est configurée de manière à être remplacée très rapidement, typiquement avec une valeur de LRU/LFU adéquate. La donnée est bien chargée dans le cache, au cas où elle doive être relue suite à une mauvaise prédiction de branchement ou autre, chose qu'une lecture non-temporelle (qui contourne le cache) ne fait pas. Des indications de ce type sont appelées des '''''cache hint'''''. L''''instruction ''flush''''' permet de préciser qu'une ligne de cache contient une donnée inutile, qui ne sera pas réutilisée par le programme. Pas besoin de la conserver dans le cache, elle peut laisser sa place à des données plus utiles. Or, sans indication, les algorithmes de remplacement d'une ligne de cache risquent de conserver cette donnée trop longtemps, ce qui entraine une certaine pollution du cache par des données inutiles. Une autre instruction est elle beaucoup plus importante : celle de '''pré-allocation sur écriture'''. Elle sert dans le cas où une ligne de cache est complétement écrite. Par exemple, imaginons qu'on veuille écrire dans une portion de mémoire. Si celle-ci n'est pas dans le cache, le processeur va charger une ligne de cache complète depuis la RAM, écrire dans la ligne de cache, puis recopier la ligne de cache modifiée en mémoire RAM. Une écriture en RAM demande donc de faire une lecture et une écriture. Mais les instructions de pré-allocation sur écriture permettent de prévenir qu'une ligne de cache sera intégralement écrite, et qu'il n'y a donc pas besoin de lire celle-ci depuis la RAM. Notons que l'instruction d'écriture qui suit n'est pas une écriture non-temporelle, vu que les données sont écrites dans la ligne de cache, qui est ensuite envoyée en mémoire RAM dès que nécessaire. De plus, les données écrites peuvent ensuite être relue depuis le cache si nécessaire. Enfin, certains processeurs MIPS incorporent une instruction pour modifier le tag d'une ligne de cache. Elles servent à optimiser les copies mémoire, à savoir quand on copie un bloc de données d'un endroit à un autre. L'idée est de charger le bloc de données dans le cache avec une instruction LOAD/PREFETCH, de modifier le tag pour qu'il pointe vers l'adresse à écrire, et de laisser faire le cache pour que l'écriture se fasse en RAM. Mais les contraintes pour utiliser cette instruction sont assez drastiques : les données doivent être alignées sur la taille d'une ligne de cache, le bloc de départ et d'arrivée (l'original versus la copie) ne doivent pas se recouvrir, etc. ==L'associativité des caches et leur adressage implicite== Lorsqu'on souhaite accéder au cache, il faut trouver quelle est la ligne de cache dont le tag correspond à l'adresse demandée. On peut classifier les caches selon leur stratégie de recherche de la ligne correspondante en trois types de caches : totalement associatifs, directement adressés (''direct mapped'') et associatifs par voie. ===Les caches totalement associatifs=== Avec les caches totalement associatifs, toute donnée chargée depuis la mémoire peut être placée dans n'importe quelle ligne de cache, sans aucune restriction. Ces caches ont un taux de succès très élevé, quand on les compare aux autres caches. [[File:Cache totalement associatif.png|centre|vignette|upright=2|Cache totalement associatif.]] Concevoir un cache totalement associatif peut se faire de deux grandes manières différentes. La première consiste tout simplement à combiner une mémoire associative avec une mémoire RAM, en ajoutant éventuellement quelques circuits annexes. La mémoire associative mémorise les tags, alors que la mémoire RAM mémorise les données de la ligne de cache, éventuellement avec quelques bits de contrôle. La ligne de cache est stockée à une adresse A dans la mémoire RAM et son tag est stocké à la même adresse, mais dans la mémoire CAM. Ce faisant, quand on envoie le tag à la mémoire CAM, elle renvoie l'adresse de la ligne de cache dans la mémoire RAM. Cette adresse est alors envoyée directement sur le bus d'adresse de la RAM, et la lecture est effectuée automatiquement. Il faut ajouter quelques circuits annexes pour garantir que les écritures se passent correctement dans les deux mémoires, mais rien de bien terrible. [[File:Cache fabriqué avec une mémoire associative et une RAM.png|centre|vignette|upright=3|Cache fabriqué avec une mémoire associative et une RAM]] Il est cependant possible d'optimiser un tel cache, en fusionnant la mémoire CAM et la mémoire RAM, afin d'éliminer des circuits redondants. Pour comprendre pourquoi, rappelons que les mémoires CAM sont composées d'un plan mémoire, d'un paquet de comparateurs et d'un encodeur. Quant à la mémoire RAM, elle est composée d'un décodeur connecté au plan mémoire. En mettant une CAM suivie d'une RAM, on a un encodeur dont l'entrée est envoyée à un décodeur. [[File:Cache totalement associatif naif.png|centre|vignette|upright=3|Cache totalement associatif naif]] Or, le décodeur réalise l'opération inverse de l'encodeur, ce qui fait que mettre les deux composants à la suite ne sert à rien. On peut donc retirer l'encodeur et le décodeur, et envoyer directement les résultats des comparateurs sur les entrées de commande du plan mémoire de la RAM. [[File:Cache totalement associatif optimisé.png|centre|vignette|upright=2|Cache totalement associatif optimisé]] Avec cette méthode, les circuits du cache ressemblent à ce qui illustré ci-dessous. Le tag est envoyé à chaque ligne de cache. Le tag envoyé est alors comparé avec le Tag contenu dans chaque ligne de cache, comme c'est le cas sur les mémoires associatives. Si une ligne de cache matche avec le tag envoyé en entrée, la ligne pour laquelle il y a eu une égalité est alors connectée sur les lignes de bit (''bitlines''). Cela est réalisé par un circuit commandé par le comparateur de la ligne de cache. Il ne reste plus qu'à sélectionner la portion de la ligne de cache qui nous intéresse, grâce à un paquet de multiplexeurs. Cela permet d'effectuer une lecture ou écriture, mais il faut aussi préciser si il y a eu un défaut de cache ou un succès. Un succès de cache a lieu quand au moins des comparaisons est positive, alors que c'est un défaut de cache sinon. En clair, détecter un succès de cache demande juste de connecter une porte OU à plusieurs entrées à tous les comparateurs. [[File:Organisation générale d'un cache totalement associatif.png|centre|vignette|upright=2|Organisation générale d'un cache totalement associatif.]] ===Les caches directement adressés=== Les caches directement adressés peuvent être vus comme un cache totalement associatif auquel on aurait ajouté des restrictions assez drastiques. Plus haut, on a vu qu'un cache totalement adressé est équivalent à la combinaison d'une CAM avec une RAM. La mémoire CAM prend en entrée un Tag et traduit celui-ci en une adresse qui commande la mémoire RAM interne au cache. Dans ce qui suit, l'adresse interne au cache sera appelé l''''indice''' pour éviter toute confusion. [[File:Cache hash table - 2.png|centre|vignette|upright=2|Fonctionnement interne du cache, expliquée sous forme abstraite, en utilisant la notion d'indice interne au cache.]] Les caches directement adressés cherchent à remplacer la mémoire CAM par un circuit combinatoire. Ce circuit traduit le Tag en indice, mais est beaucoup plus simple qu'une mémoire CAM. Mais qui dit circuit plus simple dit circuit plus limité. Un circuit combinatoire n'est pas aussi versatile que ce qui est permis avec une mémoire CAM. En conséquence, une restriction majeure apparait : toute adresse mémoire est associée dans une ligne de cache prédéfinie, toujours la même. L'association entre ligne de cache et adresse mémoire est faite par le circuit combinatoire, et ne peut pas changer. Les concepteurs de caches s'arrangent pour que des adresses consécutives en mémoire RAM occupent des lignes de cache consécutives, par souci de simplicité. Tout se passe comme suit la mémoire RAM était découpés en blocs de la même taille que le cache. La première adresse du bloc est associée à la première ligne de cache (celle d'indice 0), la seconde adresse est associée à la seconde adresse du_ bloc, et ainsi de suite. Le tout est illustré ci-dessous. [[File:Cache adressé directement.png|centre|vignette|upright=2|Cache adressé directement.]] Avec cette contrainte, le circuit de traduction de l'adresse en adresse mémoire pour la RAM interne au cache est drastiquement simplifié, et disparait même. Une partie de l'adresse mémoire sert à indiquer la position de la donnée dans le cache, le reste de l'adresse sert encode le tag et la position de la donnée dans le ligne de cache. [[File:Cache line.png|centre|vignette|upright=2|Adresse d'une ligne de cache sur un cache adressé directement.]] Un cache directement adressé est conçu avec une RAM, un comparateur, et un paquet de multiplexeurs. En général, la mémoire RAM stocke les lignes de caches complète. Il arrive que l'on utilise deux mémoires RAM : une pour les tags et une pour les données, mais cette technique augmente le nombre de circuits et de portes logiques nécessaires, ce qui réduit la capacité du cache. L'index à lire/écrire est envoyé sur l'entrée d'adresse de la RAM, la RAM réagit en mettant la ligne de cache sur sa sortie de donnée. Sur cette sortie, un comparateur compare le tag de la ligne de cache lue avec le tag de l'adresse à lire ou écrire. On saura alors si on doit faire face à un défaut de cache. Ensuite, un multiplexeur récupère la donnée à lire/écrire. [[File:Direct mapped cache - french.png|centre|vignette|upright=2|Cache directement adressé.]] L'accès à un cache directement adressé a l'avantage d'être très rapide vu qu'il suffit de vérifier une seule ligne de cache : celle prédéfinie. Mais ces caches ne sont cependant pas sans défauts. Vu que le cache est plus petit que la mémoire, certaines adresses mémoires se partagent la même ligne de cache. Si le processeur a besoin d’accéder fréquemment à ces adresses, chaque accès à une adresse supprimera l'autre du cache : tout accès à l'ancienne adresse se soldera par un défaut de cache. Ce genre de défauts de cache causés par le fait que deux adresses mémoires ne peuvent utiliser la même ligne de cache s'appelle un '''défaut par conflit''' (''conflict miss''). Les défauts par conflit n'existent pas sur les caches totalement associatifs. En conséquence, le taux de succès des caches directement adressés est assez faible comparé aux autres caches. [[File:Cache Block Basic Conflict.svg|centre|vignette|upright=1.5|Exemple de ''Conflict Miss''.]] ===Les caches associatifs par voie=== Les caches associatifs par voie sont un compromis entre les caches directement adressés et les caches totalement associatifs. Pour simplifier, ces caches sont composés de plusieurs caches directement adressés accessibles en parallèle, chaque cache/RAM étant appelé une '''voie'''. Avec ces caches, toute adresse mémoire en RAM est associée à une ligne de cache dans chaque voie. [[File:Cache associatif par voie.png|centre|vignette|upright=2|Cache associatif par voie.]] Le schéma ci-dessous compare un cache directement adressé et un cache associatif à deux voies. On voit que chaque adresse est associée à une ligne de cache bien précise avec un cache directement dressé, et à deux lignes de cache avec un cache associatif à deux voies. L'adresse sera associée à 4 lignes de cache sur un cache associatif à 4 voies, à 8 lignes pour un cache à 8 voies, etc. L'ensemble des lignes de cache associées à une adresse est appelé un '''ensemble'''. [[File:Cache Fill.svg|centre|vignette|upright=2|Comparaison entre un cache directement adressé et un cache associatif à deux voies.]] Sur ces caches, toute adresse est découpée en trois parties : un tag, un index, et un décalage, comme sur les caches directement adressés. Comme vous pouvez le voir, l'organisation est identique à celle d'un cache totalement associatif, à part que chaque ensemble tag-ligne de cache est remplacé par une mémoire RAM qui en contient plusieurs. [[File:Implémentation d'un cache associatif par voie.png|centre|vignette|upright=2|Implémentation d'un cache associatif par voie.]] Le risque de conflits d'accès au cache est donc réduit sur un cache associatif à plusieurs voies, et il est d'autant plus réduit que le cache a de voies. Par contre, leur conception interne fait qu'ils ont un temps d'accès légèrement élevé que les caches directement adressés. Les caches associatifs par voie ont donc un taux de succès et un temps d'accès intermédiaire, situé entre les caches directement adressés et totalement associatifs. Ils sont une sorte de compromis entre réduction des défaut par conflits d'accès au cache et temps d'accès, et complexité des circuits. ==Les optimisations des caches associatifs par voie== Les caches partiellement associatifs regroupent les caches associatifs par voie et directement adressés, ainsi que leurs variantes. En clair : tous les caches qui ne sont pas totalement associatifs. Ils peuvent être optimisés de nombreuses manières, que ce soit pour gagner en performance ou pour économiser de l’énergie. Dans cette section, nous allons voir quelles sont ces optimisations. ===Les caches pseudo-associatifs=== Les caches adressés par voie contiennent une mémoire SRAM par voie. En théorie, les voies sont accédées en parallèles, en même temps, afin de voir si l'on a un succès de cache ou un défaut. Les '''caches pseudo-associatifs''' sont identiques aux caches associatifs par voie, si ce n'est qu'ils vérifient chaque voie une par une. Ils ont été utilisés sur des processeurs commerciaux, un exemple étant l'IBM 370. Là encore, on perd en performance pour gagner en consommation d'énergie. Le temps d'accès dans le meilleur des cas est plus faible pour les caches pseudo-associatifs, mais le pire des cas teste tous les caches avant de tomber sur le bon. Les performances sont donc réduites. Mais la consommation énergétique est meilleure, vu qu'on ne vérifie pas forcément toutes les voies en parallèle. On teste la première voie, éventuellement la seconde, peut-être la troisième, etc. Mais dans le cas général, on ne teste qu'une partie des voies, pas toutes, ce qui donne un gain en termes d'énergie. L'implémentation de caches de ce genre demande que l'on parcoure les voies une par une, en commençant de la première jusqu'à la dernière. Pour cela, un simple compteur suffit. Suivant la valeur du compteur, la voie associée est activée puis accédée. Toute la complexité revient à ajouter un circuit qui prend la valeur du compteur, et active la voie associée, lance un accès mémoire dessus. Vu que les voies sont chacune des caches ''direct mapped'', il suffit pour cela de geler les entrées d'adresse, soit en les déconnectant, soit en utilisant du ''clock gating'' ou de l'évaluation gardée. Les détails d'implémentation, non-cités ici, varient selon le cache. ===La prédiction de voie=== Pour réduire le temps d'accès des caches pseudo-associatifs, certains chercheurs ont inventé la '''prédiction de voie''', qui consiste à faire des paris sur la prochaine voie accédée. L'idée est d'accéder à la voie qui contient la donnée voulue du premier coup, en lisant celle-ci en priorité. Dans son implémentation la plus simple, le cache reste un cache pseudo-associatif. Lors d'un accès au cache, les voies sont toutes parcoures une par une. Par contre, les voies ne sont donc pas parcourues de la première vers la dernière, mais dans un ordre différent. Cette technique permet de mettre en veille les voies sur lesquels le processeur n'a pas parié, ce qui permet de diminuer la consommation énergétique du processeur. C'est plus efficace que d'aller lire plusieurs données dans des voies différentes et de n'en garder qu'une. L'implémentation est assez simple : il suffit d'ajouter un circuit de prédiction de voie,relié au compteur de voie. Une amélioration de la technique fait fonctionner le cache comme un intermédiaire entre cache pseudo-associatif et associatif par voies. L'idée est de chercher la voie prédite en premier, puis de chercher dans toutes les voies en parallèle en cas de défaut de cache. Au lieu d'attendre que les comparaisons de tags donnent leur résultat, le processeur sélectionne automatiquement une voie et configure les multiplexeurs à l'avance. Si le processeur ne se trompe pas, le processeur accède à la donnée plus tôt que prévu. S'il se trompe, le processeur annule la lecture effectuée en avance et recommence en faisant un accès en parallèle aux autres voies. Le compromis entre performance et consommation d'énergie est alors différent. On économise de l'énergie par rapport à un cache associatif par voie, au prix d'une petite perte de performance (doublement des temps d'accès). Mais par rapport à un cache pseudo-associatif, l'économie d'énergie est bien moindre, au prix d'un gain en performance assez manifeste. Prédire quelle voie sera la bonne est assez simple. En vertu du principe de localité, les accès futurs ont des chances de tomber dans les voies les plus fréquemment utilisées ou dans celle plus récemment utilisée. Il suffit de retenir la voie la plus récemment accédée dans un registre, qui sera utilisée comme prédiction. Pour vérifier que la prédiction est correcte, il suffit de comparer le registre et le résultat obtenu après vérification des tags. Cependant, on peut complexifier l'implémentation pour prendre en compte l'adresse à lire/écrire, l'instruction à l'origine de l'accès mémoire ou tout autre paramètre utile. Par exemple, des instructions différentes ont tendance à aller chercher leurs données dans des ensembles différents et la voie à choisir n'est pas la même. Pour cela, il suffit d'utiliser un cache pour stocker la correspondance instruction - voie. Pour plus de simplicité, la mémoire cache des prédictions est parfois remplacée par une RAM, qui est adressée : * soit par le program counter de l'instruction à l'origine de l'accès (en réalité, seulement quelques bits de poids faible de l'adresse) ; * soit par l'adresse à accéder (là encore, quelques bits de poids faible) ; * soit (pour les modes d'adressage qui utilisent un registre de base et un décalage) par un XOR entre les bits de poids faible de l'adresse de base et le décalage ; * soit par autre chose. ===La mise en veille sélective des voies=== Les caches associatifs ont tendance à utiliser beaucoup d'énergie, même quand on n'y accède pas. Aussi, certains processeurs détectent quand le cache est peu utilisé et en profitent pour mettre en veille les voies inutilisées. Vous vous demandez certainement ce qui se passe quand une donnée à lire/écrire est dans une voie désactivée. La réponse est que le cache détecte cette situation, car elle déclenche un succès de cache. Les ''tags'' ne sont en effet pas désactivés, seules les données sont mises en veille. L'implémentation est plus simple sur les caches qui séparent les tags et les données dans deux RAM différentes. Cette optimisation marche surtout sur les gros caches, qui ont des chances d'avoir une portion significative d’inutilisée (pas assez de données pour les remplir), donc généralement les caches L3/L4. Par exemple, les processeurs d'Intel de microarchitecture Ivy Bridge disposent d'un cache de 8 mébioctets à 16 voies, qu'ils peuvent faire passer à 512 kibioctets si le besoin s'en fait sentir. Quand ces processeurs détectent une faible activité, ils mettent en veille 14 voies et n'en gardent que 2 d'actives. Évidemment, les 14 voies sont vidées avant d'être mises en veille, afin qu'une aucune donnée ne soit perdue. ===Les caches ''skew-associative''=== Vous aurez remarqué que dans une voie, les lignes sont accédées en adressage direct : les défauts par conflit sont possibles sur un cache associatif par voie. Pour éviter cela, certains chercheurs ont créé des '''caches ''skew associative''''' (ou associatifs à biais). Pour faire simple, les index des lignes de cache subissent un petit traitement avant d'être utilisés. Le traitement en question est différent suivant la voie de destination, histoire que deux adresses mémoires avec des index identiques donnent des index différents après traitement. Le traitement en question est souvent une permutation des bits de l'index, qui est différente suivant la voie prise, ou un simple XOR avec un nombre qui dépend de la voie. [[File:Implémentation d'un cache skew associative.jpg|centre|vignette|upright=2|Implémentation d'un cache skew associative.]] ==Les caches splittés (''phased caches'')== Dans cette section, nous allons voir les '''caches splittés''' (''phased caches''), qui sont une variante des caches ''direct-mapped'', dans lequel le cache est accédé en deux étapes consécutives. Il ne s'agit pas des caches pipelinés, que nous verrons dans le chapitre sur les processeurs pipélinés, mais laissons cela à plus tard. Il est possible d'appliquer la même méthode sur un cache associatif par voie, mais il y a des méthodes plus simples, qui permettent là aussi d’accéder au cache en plusieurs étapes consécutives. L'idée est de scinder le cache en deux : une mémoire pour les tags, une autre pour les données de la ligne de cache. Les bits de contrôle peuvent être mis dans l'une ou l'autre SRAM, mais ils sont souvent mis dans la RAM pour les tags. En faisant cela, quelques optimisations deviennent possibles, afin de réduire la consommation énergétique en contrepartie d'une perte de performance. La technique s'implémente différemment pour les caches totalement associatifs et partiellement associatifs. Les caches totalement associatifs splittés sont ceux formés en combinant un cache associatif avec une CAM et une RAM combinée. On envoie l'adresse à lire/écrire à la mémoire associative, elle répond en envoyant une adresse à la mémoire RAM. L'accès se fait donc en deux temps, avec l'adresse dans la RAM comme intermédiaire. Il est possible de séparer physiquement les deux étapes en insérant un registre entre la CAM et la RAM, ce qui permet aussi de pipeliner l'accès. Mais c'est rarement fait en pratique, car le cout en circuit d'une mémoire CAM est trop important. L'équivalent pour un cache totalement associatif optimisé, sans CAM et RAM séparée, est trop gourmande en interconnexions pour être implémentée. Les caches totalement associatifs splittés sont donc très rares, l'auteur ne connait aucun exemple de processeur avec un tel cache. Il existe une technique équivalente pour les caches ''direct-mapped'', mais elle demande une certaine modification du cache. Dans les caches ''direct-mapped'' non-splittés, on trouve une mémoire SRAM dont chaque mot mémoire contient une ligne de cache entière, tag inclus. Dans leurs versions splittés, la SRAM est séparée en deux : une pour les tags, une autre pour les données. Précisons qu'il s'agit bien de deux mémoires SRAM adressables. L'adresse à laquelle accéder est envoyée à la SRAM des tags, puis ensuite à la SRAM des données si besoin. L'idée est d’accéder aux tags pour déterminer s'il y a un succès de cache ou un défaut, et ensuite d'accéder aux données. On n’accède pas aux données en parallèle des tags. Faire cela est évidemment plus lent. En cas de défaut de cache, le temps d'accès est similaire : le tag ne correspond pas, on n'accède pas à la SRAM pour les données. Par contre, vu qu'on n'a pas activé la SRAM pour les données, on économise un peu d'énergie, ce qui réduit la consommation d'énergie. En cas de succès de cache, on accède à la SRAM pour les tags, puis à celle pour les données. Pas d'économie d'énergie à l'horizon, sans compter que le temps d'accès augmente : on accède au cache en deux étapes au lieu de faire les deux accès en parallèle. [[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]] Précisons cependant que ce design peut avoir deux avantages en termes de performance. Premièrement, le temps d'accès au cache est légèrement amélioré en cas de défaut de cache. En effet, la SRAM des tags est assez petite, idem pour celle des données. Leur temps d'accès est donc plus faible que pour une grosse SRAM contenant données et tags. Le gain en temps d'accès est donc un avantage, qui ne se manifeste surtout en cas de défaut de cache. Un autre avantage est que l'accès au cache se pipeline plus facilement, ce qui fait qu'on peut effectuer plusieurs accès simultanés au cache. Mais nous verrons cela dans quelques chapitres. ===L'exemple des processeurs 386 et du contrôleur de cache 82385=== Il est important de noter que la séparation entre tags et RAM peut être telle que les deux ne sont pas sur la même puce de silicium ! Voire que les deux sont séparés du processeur ! C'était le cas quand les mémoires caches ont été introduites sur les processeurs grand public, notamment sur les premiers processeurs Intel. La miniaturisation n'avait pas avancé au point où placer un cache dans le processeur était possible. Sur le processeur 386 d'Intel, le cache était un cache splitté, séparé du processeur. Concrètement, le processeur i386 était couplé à un contrôleur de cache Intel 82385 et une mémoire SRAM. Le 82385 contenait les ''tags'' et les bits de contrôle, la SRAM contenait les données, les lignes de cache. Un point important est que les lignes de cache faisaient seulement 32 bits/4 octets, pas plus ! On était loin des lignes de cache actuelles, faisant 64 octets/512 bits. Mais c'était beaucoup plus pratique, vu que le bus système faisait 32 bits de large, idem pour l'interface avec le processeur. Pour intégrer un cache facultatifs (certains ordinateurs 'en passaient). Le schéma ci-dessous montre comment le cache s'intégrait avec le bus système. Pour le bus de commande, le cache servait d'intermédiaire : il recevait les commandes et et les filtrait suivant les succès/défauts de cache. En cas de succès de cache, les commandes de lecture n'étaient pas envoyées à la mémoire RAM. Les adresses étaient transmises à la fois au cache et au bus système (avec un registre entre le bus système et le processeur). Le bus de donnée était lui connecté à la mémoire SRAM et au processeur, avec des MUX/DEMUX pour faire le choix de la source des lectures. Le 82385 surveillait ce qui se passait sur le bus et répondait à la place de la RAM pour certaines lectures. C'était un intermédiaire assez passif, qui se contenait de répondre aux succès et défauts en lecture. Le cache était un cache ''write through'' un peu particulier. En cas de succès de cache pour une écriture, le cache met à jour sa ligne de cache et propage l'écriture en mémoire RAM. Par contre, si une écriture fait un défaut de cache, la donnée n'est pas écrite dans le cache. Le seul moyen pour copier une donnée dans le cache était un défaut pour une lecture. [[File:Controleur de cache 82385 pour l'Intel 386.png|centre|vignette|upright=2.5|Contrôleur de cache 82385 pour l'Intel 386]] Le 82385 pouvait commander soit un cache ''direct mapped'', soit associatif à deux voies. La différence entre les deux est que le 82385 était relié à une mémoire SRAM avec un cache ''direct mapped'', deux SRAM pour deux voies. Pour avoir un cache associatif à deux voies, le 82385 devrait gérer deux signaux ''chip select'' pour activer chaque SRAM/voie suivant les besoins. Il avait précisément quatre signaux CS : deux par SRAM, un pour les lectures, un pour les écritures. Notons que les SRAM utilisées devaient être des SRAM avec un bus de données de 32 bits, des SRAM 16 ou 8 bits ne fonctionnaient pas. Le 82385 rajoutait 4 sorties, pour masquer chaque octet dans ces 32 bits. Le choix entre les deux était le fait d'une entrée : la mettre à 0 indiquait un cache ''direct mapped'', la mettre à 1 forçait un cache à deux voies. Il gérait aussi les accès mémoire non-cacheable, à savoir des accès mémoire qui ne doivent pas être pris en compte par le cache. Pour cela, il avait une entrée NCA (''Non Cacheable Access'') : le cache était "désactivé" quand cette entrée était à 1. C'est un peu une sorte de ''chip select'' pour le 82385, limitée aux accès mémoire. Cette entrée permettait de programmer des intervalles d'adresse auxquels ne pas répondre, en utilisant des circuits de décodage d'adresse adaptés. Il avait aussi une entrée X16, qui permettait d'identifier les accès soit à un composant 16 bits, soit à une entrée-sortie. De tels accès ne doivent pas être mis en cache, ce qui était garanti par cette entrée. Pour rappel, le processeur utilisait un espace d'adressage séparé pour les entrées-sorties, et utilisait donc un bit IO. Ce bit IO pouvait être utilisé pour déterminer le bit X16, qui inhibe l'action du cache. Cette surveillance du bus permettait de gérer une forme limitée de cohérence des caches par invalidation. Dès que le 82385 détectait une prise de contrôle du bus par autre chose que le processeur, il surveillait les adresses transmises sur le bus. En cas de succès de cache, la ligne de cache associée était invalidée. Au-delà de ca, le 82385 avait une entrée FLUSH, qui ordonnait une invalidation totale du cache. Si cette entrée est mise à 1, toutes les lignes de cache sont invalidées. Les ''tags'' sont marqués comme invalides, mais les lignes de cache elles-mêmes ne sont pas touchées. Le 82385 pouvait être intégré dans un système à deux processeurs, voire plus. Pour cela, chaque processeur avait son propre 82385 et sa SRAM rien qu'à lui. Il n'y avait pas de cache partagé entre les deux processeurs. Par contre, les deux caches étaient reliés au même bus système. Pour qu'ils ne se marchent pas sur les pieds, il y avait des circuits d'arbitrage pour gérer l'accès au bus. Un des deux 82385 était mis en mode maitre, l'autre était en mode esclave. Le 82385 maitre pouvait prendre le contrôle du bus, le 82385 esclave devait demander l'autorisation au premier pour accéder au bus système. ===L'exemple des processeurs Intel de microarchitecture ''Broadwell''=== Un autre exemple est celui du cache L4 des processeurs Broadwell et de quelques processeurs séparés. Ces processeurs ont une organisation en ''chiplet'' où le processeur incorpore plusieurs puces séparées : une puce pour le processeur proprement dit, une puce nommée ''Crystal Well'' pour le cache L4, et une puce IO pour la communication avec la RAM et la carte mère. Le processeur incorporait un cache L4 de 128 mébioctets, composé de mémoire eDRAM, qui était dispersé entre ''Crystal Well'' et les autres puces. Les données du cache L4 étaient dans ''Crystal Well'', alors que les Tags étaient soit dans le processeur lui-même, soit dans la puce IO ! La puce ''Crystal Well'' était une mémoire DRAM adressable tout ce qu'il y a de plus basique, avec cependant quelques optimisations notables. Par exemple, elle avait deux bus séparés pour l'écriture et la lecture. De plus, elle avait une organisation interne avec 128 banques, contre moins d'une dizaine pour la DDR de l'époque et environ 32 banques pour la DDR5 moderne. Elle contenait aussi quelques circuits pour gérer son rôle de mémoire cache, mais rien en ce qui concerne la gestion des tags eux-mêmes. Sur les processeurs de microarchitecture ''Broadwell'', les tags étaient placés dans le CPU et précisément dans le cache L3. À chaque accès mémoire au cache L3, les tags du cache L4 étaient consultés en parallèle. De fait, l'accès au cache L4 était assez rapide, malgré le fait que les données étaient dans une puce à part. Ajoutons à cela que le processeur et ''Crystal Well'' n'avaient pas la même finesse de gravure ni la même technologie de fabrication. Les tags étaient implémentés avec de la SRAM contre la DRAM pour les données, ce qui fait que la consultation des tags était plus rapide que l'accès aux données. Par la suite, dans certains CPU de microarchitecture ''skylake'', les tags ont été déplacés en-dehors du processeur pour finir dans le contrôleur mémoire. En faisant cela, le cache L4 pouvait être utilisé par autre chose que le processeur, et notamment par la carte graphique intégrée au CPU. Avec ''broadwell'', le fait que les tags étaient consultés en cas d'accès au L3 empêchait au GPU intégré de consulter le cache L4. Mais en déplaçant les tags dans le contrôleur mémoire, ce n'est plus le cas vu que la carte graphique a aussi accès au bus mémoire. Par contre, le temps d'accès augmente comparé à la solution précédente. On n'accède pas aux tags du L4 en parallèle du L3 : à la place, il faut consulter les tags du L3, détecter un défaut de cache L3, et ensuite accèder aux tags. ===Les caches RAM-configurables=== Un autre avantage des caches splittés est qu'on peut les modifier pour servir à la fois de mémoire cache, mais aussi de ''local store'', de mémoire RAM de petite taille. Le fonctionnement est assez simple à comprendre. Lors d'un accès au cache, on accède aux tags, puis à la RAM interne au cache. Lors d'un accès au ''local store'', on contourne l'accès au tags et on accède à la RAM interne au cache directement. Il s'agit de la technique du '''cache RAM-configurable''. L'usage de cache RAM-configurable est fréquent sur les cartes graphiques récentes, qui incorporent un ou plusieurs processeurs multicoeurs, dont le cache L1 de données est un cache RAM-configurable. [[File:Hydride cache - local store.png|centre|vignette|upright=2.0|Hydride cache - local store]] ===La compression de cache=== Une autre optimisation permise par les ''phased caches'' est l'implémentation de techniques de '''compression de cache''', qui visent à compresser des lignes de cache. L'intérêt est qu'on peut stocker plus de données dans le cache, à capacité égale. L'inconvénient est qu'on doit compresser/décompresser les lignes de cache, ce qui demande un circuit en plus et allonge les temps d'accès. En effet, le temps mis pour compresser/décompresser une ligne de cache s'ajoute au temps d'accès. Aussi, la compression de cache sert surtout pour les caches de bas niveau dans la hiérarchie mémoire, les gros caches aux temps d'accès assez longs. Une première technique, assez simple à implémenter et peu couteuse en circuit, est celle de la '''compression des lignes de cache nulles'''. Elle compresse uniquement les lignes de cache qui ne contiennent que des zéros. L'idée est qu'on ajoute, dans la mémoire des tags, un bit de contrôle pour chaque ligne de cache appelé le bit ''null''. Il indique si la ligne de cache ne contient que des zéros. Quand on lit une ligne de cache, la mémoire des tags est accédée et on vérifie le bit ''null'' : s'il vaut 1, on n'accède pas à la mémoire cache de données et un multiplexeur envoie un zéro sur le port de lecture. Le bit ''null'' est fixé lors de l'écriture d'une ligne de cache : elle passe dans un comparateur avec zéro relié à la mémoire des tags. La comparaison avec zéro peut se faire en parallèle de l'écriture ou avant (dans ce cas, on n'écrit pas la ligne de cache dans le cache). Les autres techniques de compression de cache permettent de compresser autre chose que des lignes de cache nulles. L'idée est qu'une ligne de cache physique peut par moment mémoriser plusieurs lignes de caches compressées. Par exemple, prenons un cache dont les lignes de cache font 64 octets. Il est possible de compresser deux lignes de cache pour qu'elles fassent chacune 32 octets, et les stocker dans une seule ligne de cache. Les deux lignes de cache auront des tags différents, mais pointeront sur la même ligne de cache physique. Et cela demande d'utiliser un ''phased cache'' dont la mémoire pour les tags est plus grande que la mémoire pour les données. Il n'y a donc plus une bijection entre tags et ligne de cache, mais une relation surjective. Chose qui n'est possible qu'avec un ''phased cache''. De plus, des bits de contrôles associés à chaque ''tag'' indiquent où se trouvent les lignes de cache compressées dans la ligne de cache : est-ce que c'est les 32 octets de poids fort ou de poids faible ? [[File:Compression de cache.png|centre|vignette|upright=2|Compression de cache]] Il ne semble pas que les techniques de compression de cache soient implémentées sur les processeurs modernes. Aucun n'utilise de compression de cache, à ma connaissance. Il faut dire que les techniques connues sont de mauvais compromis : le temps d'accès du cache augmente beaucoup, le cout en circuit pourrait être utilisé pour un cache non-compressé mais plus grand. Et notons que la compression de cache ne marche que si les données peuvent se compresser. Si ce n'est pas le cas, une partie de la mémoire des tags est inutilisée. Une revue de la littérature académique sur la compression de cache est disponible via ce lien, pour les curieux : * [https://inria.hal.science/hal-03285041 Understanding Cache Compression, par Carvalho et Seznec]. ==L'adressage physique ou logique des caches== Le cache utilise les adresses à lire/écrire pour déterminer s'il a une copie de la donnée en son sein. Mais l’interaction entre caches et mémoire virtuelle donne lieu à un petit problème : l'adresse utilisée est-elle une adresse virtuelle/logique ou physique ? La réponse varie suivant le processeur : certains caches utilisent l'adresse virtuelle, tandis que d'autres prennent l'adresse physique. On parle de cache '''virtuellement tagué''' dans le premier cas et de cache '''physiquement tagué''' dans le second. {| |[[File:Cache tagué virtuellement.png|vignette|Cache tagué virtuellement.]] |[[File:Cache tagué physiquement.png|vignette|Cache tagué physiquement.]] |} ===L'accès à un cache physiquement/virtuellement tagué=== La manière d'accéder à un cache dépend de s'il est virtuellement ou physiquement tagué. Il faut utiliser l'adresse virtuelle pour les premiers, physique pour les seconds. Avec un cache virtuellement tagué, l'adresse logique peut être envoyée directement au cache. La MMU ne traduit les adresses que s'il faut accéder à la mémoire RAM. Ces caches sont donc plus rapides. Avec un cache physiquement tagué, le processeur doit traduire l'adresse logique en adresse physique dans la MMU, avant d'accéder au cache. La traduction d'adresse se fait soit en accédant à une table des pages en mémoire RAM, soit en accédant à un cache spécifiquement dédié à accélérer la traduction d'adresse, la TLB (''Translation Lookaside Buffer''). Dans la quasi-totalité des cas, la traduction d'adresse passe par la TLB, ce qui fait qu'elle est raisonnablement rapide. Toujours est-il que chaque accès au cache demande d'accéder à la TLB et de faire la traduction d'adresse avant d'accéder au cache. L'accès est donc plus lent que sur les caches virtuellement tagués, où les accès sont plus directs. [[File:Virtual and Physical addressing.svg|centre|vignette|upright=2|Cache tagué virtuellement versus physiquement tagué.]] ===Les défauts des caches virtuellement tagués=== Les caches physiquement tagués sont moins rapides que les caches virtuellement adressés. Pourtant, les caches virtuellement tagués sont peu fréquents sur les processeurs modernes. Et la raison est assez intéressante : c'est une question d'adresses homonymes et synonymes. ====Les droits d'accès doivent être vérifiés lors d'un accès au cache==== Un premier problème est que la protection mémoire est compliquée avec de tels caches. Rappelons que certaines portions de mémoire sont accessibles seulement en lecture, ou sont interdites en écriture, sont inexécutables, etc. Ces droits d'accès sont gérés par la MMU, qui vérifie pour chaque accès mémoire que l'accès est autorisé. En bypassant la MMU, l'accès au cache virtuellement tagué ne permet pas de faire ces vérifications. Il est possible de charger une donnée en lecture seule dans le cache, mais d'y faire des accès en écriture pour les accès ultérieurs. Les solutions à cela sont multiples. La première consiste à consulter la MMU en parallèle de l'accès au cache. L'accès au cache est alors réalisé de manière spéculative, et est ensuite confirmé/annulé une fois que la MMU a rendu son verdict. Les performances du cache restent alors les mêmes : l'accès à la MMU se fait en parallèle de l'accès au cache, pas avant. Une autre solution est d'ajouter les droits d'accès en question dans la ligne de cache, dans les bits de contrôle situés après le Tag. Chaque accès au cache récupère ces bits de contrôle et vérifie si l'accès est autorisé. L'inconvénient est que les lignes de cache deviennent plus longues, les droits d'accès sont dupliqués entre MMU et cache. Mais si le budget en transistor suit, ce n'est rien d'insurmontable. ====Les adresses homonymes perturbent la gestion du cache==== Pour rappel, une adresse logique homonyme correspond à plusieurs adresses physiques différentes. Elles surviennent quand chaque programme a son propre espace d'adressage. Dans ce cas, une adresse logique correspondra à une adresse physique différente par programme.Une autre manière de voir les choses est qu'il y a en réalité deux adresses homonymes, qui ont la même valeur, mais appartiennent à des espaces d'adressage différentes. Et c'est cette seconde interprétation que nous allons utiliser. Les caches doivent gérer ces adresses homonymes et faire en sorte que la lecture/écriture d'une adresse homonyme se fasse à la bonne adresse physique, dans la bonne ligne de cache. Et autant un cache physiquement tagué n'a aucun problème avec ça, vu qu'il ne gère que des adresses physiques, autant des problèmes surviennent avec les caches virtuellement tagués. Le problème est que les caches virtuellement tagués doivent faire la différence entre deux adresses homonymes de même valeur. Pour corriger ces problèmes, il existe deux grandes méthodes. La première méthode est simple : '''vider les caches''' en changeant de programme. Leur contenu est rapatrié en mémoire RAM, puis les caches sont remis à zéro. Le vidage du cache recopie les lignes de cache ''dirty'' (modifiées) en RAM, puis efface/invalide tout le cache. C'est à cela que servent les instructions ''clean'' et d'invalidation vues plus haut, elles ont été inventées pour cette situation précise. Lorsque le système d'exploitation déclenche une commutation de contexte, à savoir qu'il change le programme en cours d'exécution, le processeur vide tous les caches du processeur. Les interruptions font la même chose, elles vide tous les caches du processeur. Une seconde méthode numérote chaque programme en cours d'exécution, chaque processus. Le numéro attribué est spécifique à chaque processus, ce qui fait qu'il est appelé un '''identifiant de processus CPU'''. Le processeur mémorise l'identifiant du programme en cours d'exécution dans un registre dédié. L'identifiant de processus CPU est utilisé lors des accès mémoire. Chaque ligne de cache contient le numéro de l'espace d'adressage associé, dans son ''tag''. Lors de chaque accès mémoire, l'ID du registre est comparé à l'ID de la ligne de cache accédée, pour vérifier que l'accès mémoire accède à la bonne donnée. Cette méthode n'est pas très économe en termes de transistors. L'usage d'identifiant de processus CPU est clairement meilleure en termes de performance, les commutations de contexte sont plus rapides. Par contre, le budget en transistor est plus important. Un autre défaut de cette méthode est que l'identifiant de processus est généralement codé sur une dizaine de bits, alors que le système d'exploitation utilise des identifiants de processus beaucoup plus larges, de 32 à 64 bits sur les CPU 32/64 bits. L'OS doit gérer la correspondance entre identifiants de processus CPU et ceux de l'OS. Parfois, pour cette raison, les OS n'utilisent pas toujours ce système d'identifiant de processus CPU. ====Les adresses synonymes perturbent aussi la gestion du cache==== La gestion des adresses synonymes est aussi un gros problème sur les caches virtuellement tagués. Pour rappel, il s'agit du cas où des adresses logiques différentes pointent vers la même adresse physique. Typiquement, quand deux programmes se partagent un morceau de mémoire, ce morceau correspondra à des adresses synonymes dans les deux espaces d'adressage. Mais il arrive que l'on ait des adresses synonymes dans le même espace d'adressage, ce n'est pas si rare ! Autant les adresses synonymes ne posent aucun problème avec les caches physiquement tagués, ce n'est pas le cas avec les caches virtuellement adressés. Sur ces caches, deux adresses logiques synonymes vont tomber dans deux lignes de cache différentes. Corriger ce problème demande d'ajouter des circuits annexes pour détecter les adresses synonymes, qui sont vraiment complexes et ont un cout en termes de performance. Aussi, les caches virtuellement tagués sont très peu utilisés sur les processeurs modernes. ===Les caches virtuellement adressés, mais physiquement tagués=== Si les caches physiquement et virtuellement tagués ont des défauts, il existe un intermédiaire qui est un bon compromis entre ces deux extrêmes. Il s'agit des '''caches virtuellement adressés - physiquement tagués''', aussi appelés '''caches pseudo-virtuels'''. Pour comprendre comment ils fonctionnent, précisons que ces caches sont soit des caches ''direct-mapped'', soit des caches associatifs par voie (composés de plusieurs RAM ''direct-mapped'' accédées en parallèle, plusieurs voies). L'accès à ce genre de cache se fait en deux temps : on accède à un ou plusieurs RAM ''direct-mapped'' et on vérifie ensuite les ''Tags'' pour sélectionner la bonne voie. Sur les caches ''direct-mapped'', on n'a qu'une seule RAM ''direct-mapped''. Sur les caches associatifs, on a plusieurs RAM ''direct-mapped'', appelées des voies, qui sont accédées en parallèle. L'accès se fait donc en deux étapes : adresser les RAM ''direct-mapped'' avec un indice, vérifier les ''tags'' avec le reste de l'adresse. Une autre chose à rappeler est que l'adresse logique est composée de deux parties : un numéro de page logique qui indique dans quel page se situe l'adresse, un décalage/''offset'' qui indique la position de l'adresse dans la page. La traduction d'adresse transforme le numéro de page logique en numéro de page physique, mais laisse le décalage intouché. L'idée est d'utiliser le décalage pour adresser les RAM avec le décalage, tandis que le numéro de page sert de ''tag''. Le décalage est découpé en deux lors de l'accès au cache : les bits de poids fort forment l'indice (l'adresse envoyée à la voie), les bits de poids faible donnent la position de l'adresse dans la ligne de cache. L'idée est d'utiliser un numéro de page physique pour les ''tags'', mais d'adresser les voies avec le décalage logique. Les deux servent à des instants différents : vérification des ''tags'' pour l'adresse physique, accès aux voies pour l'adresse logique. Ainsi, le problème des adresses synonymes ou homonymes est résolu par l'utilisation de l'adresse physique pour les tags. Par contre, l'accès au cache est plus rapide, car on utilise l'adresse logique pour la première étape. Le processeur accède à la TLB et récupère l'adresse physique pendant que l'on adresse les voies, les deux sont faits en parallèle, ce qui fait que tout se passe comme si l'accès à la TLB était gratuit. La TLB étant assez rapide comparé au cache, l'adresse physique est disponible quand on doit faire la comparaison avec les ''tags''. [[File:Virtual - Physical - Pseudo Virtual addressing.svg|centre|vignette|upright=2|Adressage pseudo virtuel des caches.]] Il s'agit d'un excellent compromis entre performance et correction des problèmes des adresses synonymes/homonymes. Tous les caches des processeurs haute performance utilisent cette méthode, au moins pour leurs caches L1. Les caches L2 tendent à utiliser des caches physiquement adressés, pour lesquels la latence d'accès est suffisante pour qu'on accède à la TLB en amont. La raison est assez simple à expliquer, elle provient d'une contrainte assez précise sur le calcul de l'indice. La conséquence est qu'un cache ''direct-mapped'' ne peut pas dépasser la taille d'une page, soit 4 kibioctets sur les ordinateurs actuels. Sur les caches associatifs, on peut dépasser cette limite en augmentant le nombre de voies, mais la taille maximale d'une voie reste celle d'une page. Cette contrainte n'est pas trop grave sur les caches de petite taille, dont les caches L1. La plupart d'entre eux ont trouvé un compromis idéal avec moins d'une dizaine de voies par cache, chacun de 4 kibioctets, ce qui donne des caches allant de 16 à 64 kibioctets, soit entre 4 et 16 voies. Par contre, un cache de grande taille doit utiliser un grand nombre de voies, ce qui est peu pratique. Aussi, cette technique de caches pseudo-virtuels n'est pas toujours appliquée sur les caches L2, qui sont physiquement adressés. Il faut dire qu'on accède au cache L2 lors d'un défaut dans le cache L1, et l'adresse physique est disponible à ce moment-là, elle a déjà été récupérée lors de l'accès au cache L1. On peut donc l'utiliser pour adresser le cache L2 sans perte de performance. ==Le remplacement des lignes de cache== Lorsqu'un cache est rempli et qu'on charge une nouvelle donnée dedans, il faut faire de la place pour cette dernière. Dans le cas d'un cache directement adressé, il n'y a rien à faire vu que la ligne de cache à évincer est déterminée lors de la conception du cache. Mais pour les autres caches, la donnée peut aller dans n'importe quelle ligne ou voie. Or, le choix des données à rapatrier en RAM doit être le plus judicieux possible : on doit virer de préférence des données inutiles. Rapatrier une donnée qui sera surement utilisée sous peu est inutile, et il vaudrait mieux supprimer des données qui ne serviront plus ou alors dans longtemps. Il existe différents algorithmes spécialement dédiés à résoudre ce problème efficacement, directement câblés dans les unités de gestion du cache. Certains sont vraiment très complexes, aussi je vais vous présenter quelques algorithmes particulièrement simples. Mais avant de voir ces algorithmes, il faut absolument que je vous parle d'une chose très importante. Quel que soit l'algorithme en question, il choisit la ligne de cache à évincer et recopie son contenu dans la RAM. Ce qui demande d'identifier et de sélectionner une ligne de cache parmi toutes les autres. Pour cela, le circuit de remplacement attribue une adresse chaque ligne de cache ! Vous avez bien vu : chaque ligne de cache est numérotée par une adresse, interne au cache. ===Le remplacement aléatoire=== Premier algorithme : la donnée effacée du cache est choisie au hasard ! C'est contre-intuitif, mais cet algorithme donne des résultats assez honorables, en plus d'utiliser très peu de portes logiques (un générateur de nombres pseudo-aléatoire est un circuit assez simple). Généralement, les défauts de cache sont séparés par un nombre assez important et irrégulier de cycles d'horloge. Dans ces conditions, cette technique donne un bon résultat. ===FIFO : first in, first out=== Avec l'algorithme FIFO, la donnée effacée du cache est la plus ancienne, celle chargée dans le cache avant les autres. Cet algorithme est très simple à implémenter en circuit, concevoir une mémoire de type FIFO n'étant pas très compliqué, comme on l’a vu dans le chapitre dédié à ce type de mémoires. Et on peut dire que dans le cas d'un cache, l'implémentation est encore plus simple et se contente d'un seul registre/compteur. Typiquement, il suffit d'ajouter un registre qui mémorise où se situe la donnée la plus récente. Toute insertion d'une nouvelle donnée se fait à l'adresse suivante, ce qui demande juste d'incrémenter le registre avant d'utiliser son contenu pour l'accès mémoire. [[File:Algorithme FIFO de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme FIFO de remplacement des lignes de cache.]] Cet algorithme possède une petite particularité sur les caches associatifs par voie : en augmentant le nombre d'ensembles, les performances peuvent se dégrader : c'est ce qu'on appelle l''''anomalie de Bélády'''. ===MRU : most recently used=== Avec l'algorithme MRU, la donnée remplacée est celle qui a été utilisée le plus récemment. Cet algorithme s'implémente simplement avec un registre, dans lequel on place le numéro de la dernière ligne de cache utilisée. Cet algorithme de remplacement est très utile quand un programme traverse des tableaux du premier élément jusqu'au dernier : les données du tableau sont rarement réutilisées, rendant le cache inutile. Il est prouvé que dans ces conditions, l'algorithme MRU est optimal. Mais dans toutes les autres conditions, cet algorithme a des performances assez misérables. ===LFU : least frequently used=== Avec l'algorithme LFU, la donnée supprimée est celle qui est utilisée le moins fréquemment. Cet algorithme s'implémente en associant un compteur à chaque ligne de cache, qui est incrémenté à chaque accès mémoire. La ligne la moins récemment utilisée est celle dont le compteur associé a la plus petite valeur. Implémenter cet algorithme prend pas mal de transistors, car il faut rajouter autant de compteurs qu'il y a de lignes de cache, en plus d'un circuit pour comparer les compteurs et d'un encodeur. [[File:Algorithme LFU de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme LFU de remplacement des lignes de cache]] ===LRU : least recently used=== Avec l'algorithme LRU, la donnée remplacée est celle qui a été utilisée le moins récemment. Cet algorithme se base sur le principe de localité temporelle, qui stipule qu'une donnée accédée récemment a de fortes chances d'être réutilisée dans un futur proche. Et inversement, la donnée la moins récemment utilisée du cache est celle qui a le plus de chance de ne servir à rien dans le futur. Autant la supprimer en priorité pour faire de la place à des données potentiellement utiles. Implémenter l'algorithme LRU peut se faire de différentes manières, qui ont pour point commun d'enregistrer les accès au cache pour en déduire la ligne la moins récemment accédée. La manière la plus simple demande d'utiliser un compteur pour chaque ligne de mémoire cache, un peu comme le LFU. La différence avec le LFU est que le compteur n'est pas incrémenté lors d'un accès mémoire. À la place, ce compteur est incrémenté régulièrement, chaque incrémentation ayant lieu en même temps pour tous les compteurs. Quand un bloc est chargé dans le cache, ce compteur est mis à zéro. Quand une ligne de cache doit être remplacée, un circuit va vérifier la valeur de tous les compteurs : la ligne LRU (la moins récemment utilisée), est celle dont le compteur a la valeur la plus haute. Le circuit est composé d'un paquet de comparateurs, et d'un encodeur, comme pour l'agorithme LFU. ===Les approximations du LRU=== Implémenter le LRU demande un nombre de transistors proportionnel au carré du nombre de lignes de cache. Autant dire que le LRU devient impraticable sur de gros caches. Ce qui fait que les processeurs modernes implémentent des variantes du LRU, moins couteuses en transistors, qui donnent un résultat approximativement semblable au LRU. En clair, ils ne sélectionnent pas toujours la ligne de cache la moins récemment utilisée, mais une ligne de cache parmi les moins récemment utilisées. Ce n'est pas un problème si grave que cela car les lignes les moins récemment utilisées ont toutes assez peu de chance d'être utilisées dans le futur. Entre choisir de remplacer une ligne qui a 0,5 % de chances d'être utilisée dans le futur et une autre qui a une chance de seulement 1 %, la différence est négligeable en termes de taux de succès. Mais les gains en termes de circuits ou de temps d'accès au cache de ces algorithmes sont très intéressants. L'algorithme le plus simple consiste à couper le cache (ou chaque voie s'il est associatif) en plusieurs sections. L'algorithme détermine la section la moins récemment utilisée, avant de choisir aléatoirement une ligne de cache dans cette section. Pour implémenter cet algorithme, il nous suffit d'un registre qui mémorise le morceau le moins récemment utilisé, et d'un circuit qui choisit aléatoirement une ligne de cache. Cette technique s'adapte particulièrement bien avec des caches associatifs à voies : il suffit d'utiliser autant de morceaux que de voies. Autre algorithme, un peu plus efficace : le '''pseudo-LRU de type M'''. Cet algorithme attribue un bit à chaque ligne de cache, bit qui sert à indiquer de façon approximative si la ligne de cache associée est une candidate pour un remplacement ou non. Il vaut 1 si la ligne n'est pas une candidate pour un remplacement et zéro sinon. Le bit est mis à 1 lorsque la ligne de cache associée est lue ou écrite. Évidemment, au fil du temps, toutes les lignes du cache finiront par avoir leur bit à 1. Lorsque cela arrive, l'algorithme remet tous les bits à zéro, sauf pour la dernière ligne de cache accédée. L'idée derrière cet algorithme est d'encercler la ligne de cache la moins récemment utilisée au fur et à mesure des accès. L'encerclement commence lorsque l'on remet tous les bits associés aux lignes de cache à 0, sauf pour la ligne accédée en dernier. Au fur et à mesure des accès, l'étau se resserre autour de la ligne de cache la moins récemment utilisée. Après un nombre suffisant d'accès, l'algorithme donne une estimation particulièrement fiable. Et comme les remplacements de lignes de cache sont rares comparés aux accès aux lignes, cet algorithme finit par donner une bonne estimation avant qu'on ait besoin d'effectuer un remplacement. Le dernier algorithme d'approximation, le '''PLURt''', se base sur ce qu'on appelle un arbre de décision. Il a besoin de n − 1 bits pour déterminer la ligne LRU. Ces bits doivent être organisés en arbre, comme illustré plus bas. Chacun de ces bits sert à dire : le LRU est à ma droite ou à ma gauche : il est à gauche si je vaux 0, et à droite si je vaux 1. Trouver le LRU se fait en traversant cet arbre, et en interprétant les bits un par un. Au fur et à mesure des lectures, les bits sont mis à jour dans cet arbre, et pointent plus ou moins bien sur le LRU. La mise à jour des bits s'effectue lors des lectures et écritures : quand une ligne est lue ou écrite, elle n'est pas la ligne LRU. Pour l'indiquer, les bits à 1 qui pointent vers la ligne de cache sont mis à 0 lors de la lecture ou écriture. {| |[[File:Organisation des bits avec l'algorithme PLURt.jpg|vignette|Organisation des bits avec l'algorithme PLURt.]] |[[File:Ligne de cache pointée par les bits de l'algorithme.png|vignette|Ligne de cache pointée par les bits de l'algorithme.]] |} ===LRU amélioré=== L'algorithme LRU, ainsi que ses variantes approximatives, sont très efficaces tant que le programme respecte relativement bien la localité temporelle. Par contre, Le LRU se comporte assez mal dans les circonstances ou la localité temporelle est mauvaise mais où la localité spatiale est respectée, le cas le plus emblématique étant le parcours d'un tableau. Pour résoudre ce problème, des variantes du LRU existent. Une variante très connue, l''''algorithme 2Q''', utilise deux caches : un cache FIFO pour les données accédées une seule fois et un second cache LRU. Évidemment, les données lues une seconde fois sont migrées du cache FIFO vers le cache LRU, ce qui n'est pas très pratique. Les processeurs n'utilisent donc pas cette technique, mais celle-ci est utilisée dans les caches de disque dur. D'autres variantes du LRU combinent plusieurs algorithmes à la fois et vont choisir lequel de ces algorithmes est le plus adapté à la situation. Notre cache pourra ainsi détecter s’il vaut mieux utiliser du MRU, du LRU, ou du LFU suivant la situation. ==Les écritures dans le cache : gestion et optimisations== Les écritures se font à une adresse mémoire bien précise, qui peut ou non être chargée dans le cache. Si la donnée à écrire est chargée dans le cache, elle est modifiée directement dans le cache, mais elle ne l'est pas forcément en mémoire RAM. Suivant le processeur, les écritures sont ou non propagées en mémoire RAM. Il existe deux stratégies d'écritures, appelées respectivement le ''write-back'' et le ''write-through''. Avec un cache ''write-back'', si la donnée à mettre à jour est présente dans le cache, on écrit dans celui-ci sans écrire dans la mémoire RAM. Dans ces conditions, une donnée n'est enregistrée en mémoire que si celle-ci quitte le cache, ce qui évite de nombreuses écritures mémoires inutiles. [[File:Cache write-through.png|centre|vignette|upright=2|Cache write-through.]] Avec les caches '''Write-Through''', toute écriture dans le cache est propagée en RAM. Cette stratégie augmente le nombre d'écritures dans la mémoire RAM, ce qui peut saturer le bus reliant le processeur à la mémoire. Les performances de ces caches sont donc légèrement moins bonnes que pour les caches ''write back''. Par contre, ils sont utiles dans les architectures avec plusieurs processeurs, comme nous le verrons dans les chapitres sur les architectures multiprocesseurs. [[File:Cache write-back.png|centre|vignette|upright=2|Cache write-back.]] ===Les caches ''Write-through''=== Sans optimisation particulière, on ne peut écrire dans un cache ''write-through'' pendant qu'une écriture en RAM a lieu en même temps : cela forcerait à effectuer deux écritures simultanées, en comptant celle imposée par l'écriture dans le cache. Pour éviter cela, certains caches ''write-through'' intègrent un '''tampon d’écriture''', qui sert de file d'attente pour les écritures en RAM. C'est une mémoire FIFO dans laquelle on place temporairement les données à écrire en RAM, où elles attendent en attendant que la RAM soit libre. Grâce à lui, le processeur peut écrire dans un cache même si d'autres écritures sont en attente dans le tampon d'écriture. Par souci d'efficacité, des écritures à la même adresse en attente dans le tampon d’écriture sont fusionnées en une seule. Cela fait un peu de place dans le tampon d’écriture, et lui permet d'accumuler plus d'écritures avant de devoir bloquer le cache. Il est aussi possible de fusionner des écritures à adresses consécutives de la mémoire en une seule écriture en rafales. Dans les deux cas, on parle de '''combinaison d'écriture'''. Mais la technique du tampon d'écriture a cependant un léger défaut qui se manifeste dans une situation bien précise : quand le processeur veut lire une donnée en attente dans le tampon d’écriture. La première manière de gérer cette situation est de mettre en attente la lecture tant que la donnée n'a pas été écrite en mémoire RAM. On peut aussi lire la donnée directement dans le tampon d'écriture, cette optimisation portant le nom de '''''store-to-load forwading'''''. Dans tous les cas, il faut détecter le cas où une lecture accède à une donnée dans le tampon d'écriture. À chaque lecture, l'adresse à lire est envoyée au tampon d'écriture, qui vérifie si une écriture en attente se fait à cette adresse. Pour cela, le tampon d’écriture doit être un cache, dont chaque entrée mémorise une écriture. Chaque ligne de cache contient la donnée à écrire, et le tag de la ligne de cache contient l'adresse où écrire la donnée. Notons que cache d'écriture a une politique de remplacement de type FIFO, le tampon d'écriture non-optimisé étant une mémoire FIFO. ===Les caches ''Write-back''=== Les caches ''write-back'' ont beau avoir des performances supérieures à celles des caches ''write-through'', il existe des optimisations qui permettent d'améliorer leurs performances. Ces optimisations consistent à ajouter des caches spécialisés à côté du cache proprement dit. Ces caches permettent de mémoriser des données qui sont éliminées du cache par les algorithmes de remplacement de ligne cache, sans pour autant faire une écriture en RAM. En suivant la procédure habituelle de remplacement des lignes de cache, on doit rapatrier la ligne en RAM avant d'en charger une nouvelle. On peut améliorer la situation en faisant l'inverse : on charge la nouvelle ligne pendant que l'ancienne donnée est rapatriée en RAM. Ainsi, la nouvelle ligne est disponible plus tôt pour le processeur, diminuant son temps d'attente. Pour implémenter cette technique, on doit mémoriser l'ancienne ligne de cache temporairement dans un '''cache d’éviction''' (ou ''write-back buffer''). [[File:Cache d’éviction.png|centre|vignette|upright=2|Cache d’éviction]] Les caches directement adressés ou associatifs par voie possèdent aussi un tampon d’écriture amélioré. Pour limiter les défauts par conflit de ces caches, des scientifiques ont eu l'idée d'insérer un cache pour stocker les données virées du cache. En faisant ainsi, si une donnée est virée du cache, on peut alors la retrouver dans ce cache spécialisé. Ce cache s'appelle le '''cache de victime'''. Ce cache de victime est géré par un algorithme de suppression des lignes de cache de type FIFO. Petit détail : ce cache utilise un tag légèrement plus long que celui du cache directement adressé au-dessus de lui. L'index de la ligne de cache doit en effet être contenu dans le tag du cache de victime, pour bien distinguer deux adresses différentes, qui iraient dans la même ligne du cache juste au-dessus. [[File:Victim Cache Implementation Example.svg|centre|vignette|upright=1|Cache de victime.]] ===La configuration du fonctionnement du cache=== Sur de nombreux processeurs, il est possible de configurer la mémoire cache pour qu'elle fonctionne soit en mode ''write-back'', soit en mode ''write-through''. Pour cela, les processeurs modernes incorporent des '''registres de configuration du cache'''. Le terme ''registre de configuration du cache'' est assez transparent et indique bien quel est leur rôle. Ils configurent comment le cache est utilisé et permettent notamment de configurer le cache pour dire s'il doit fonctionner en mode ''write-back'' ou ''write-through''. Ils permettent aussi d'activer ou de désactiver la combinaison sur écriture. Les registres en question sont configurés soit par le BIOS, soit par le système d'exploitation. Ce sont des registres protégés, que les applications ne peuvent pas configurer, elles n'en ont pas le droit. Typiquement, ils ne sont accessibles en écriture qu'en mode noyau. Sur les processeurs x86, les registres de configuration du cache sont appelés des '''''Memory type range registers''''' (''MTRRs''). Les MTRRs sont assez nombreux, et il y a notamment une différence entre mode réel et protégé. Si vous vous souvenez des chapitres sur le mode d'adressage et la mémoire virtuelle, vous vous souvenez que les processeurs x86 incorporent plusieurs modes de fonctionnement. En mode réel, le processeur ne peut adresser qu'un mébioctet de RAM, avec un système de segmentation particulier. En mode protégé, le processeur peut adresser toute la mémoire et la segmentation fonctionne différemment, quand elle n'est pas simplement désactivée. Les MTRRs sont séparés en deux : ceux pour le mode réel, ceux pour le mode protégé. Les MTRRs fixes sont ceux qui configurent le cache en mode réel, ils étaient utilisés pour gérer l'accès au BIOS, à la mémoire VGA de la carte graphique, et quelques autres accès aux entrées-sorties basiques gérées nativement par le BIOS. Pour le mode protégé, les processeurs au-delà du 386 incorporent des MTRRs variables, qui servent pour les autres entrées-sorties en général, notamment les périphériques PCI, la mémoire vidéo de la carte graphique, et j'en passe. De nos jours, les registres de configuration du cache sont désuets et cette fonctionnalité est gérée directement par la mémoire virtuelle. La table des pages contient, pour chaque page mémoire, des bits de contrôle qui disent si la page mémoire est cacheable ou non. Le contournement de cache est alors géré par le système de mémoire virtuelle, le cache de TLB et tout ce qui va avec. ===L’allocation sur écriture=== Que faire quand une écriture modifie une donnée qui n'est pas dans le cache ? Doit-on écrire la donnée dans le cache, ou non ? Si la donnée est écrite dans le cache, on dit que le cache fait une '''allocation sur l'écriture''' (ou ''write-allocate''). Certains caches effectuent une telle allocation sur écriture, mais d'autres ne le font pas ou du moins pas systématiquement. L’allocation sur écriture peut se décliner en deux sous-catégories : le '''chargement à la demande''' et l''''écriture immédiate'''. Dans le premier cas, on charge la donnée à modifier dans le cache, et on la remplace avec la donnée écrite. Dans l'écriture immédiate, l'écriture a lieu directement dans le cache et la donnée à modifier n'est pas chargée dans le cache. Évidemment, seule une portion de la ligne de cache contient la donnée écrite (valide), et le reste contient des données invalides. Le cache doit savoir quelles sont les portions du cache qui sont valides : cela demande d'utiliser un ''sector cache''. [[File:Write-back with write-allocation.svg|centre|vignette|upright=2|Cache Write-back avec allocation sur écriture.]] Sans allocation sur écriture, l'écriture est transférée directement aux niveaux de cache inférieurs ou à la mémoire si la donnée à modifier n'est pas dans le cache. Certains caches de ce genre utilisent une petite optimisation : lors de toute écriture, ils supposent que l'écriture donnera un succès de cache. Si c'est le cas, la ligne de cache qui contient la donnée est mise à jour avec la donnée à écrire. Mais si ce n'est pas le cas, la ligne de cache est invalidée, et l'écriture est transférée directement à la mémoire ou aux niveaux de cache inférieurs. [[File:Write-through with no-write-allocation.svg|centre|vignette|upright=2|Cache Write-through sans allocation sur écriture.]] ===La cohérence des caches=== Il arrive parfois que la mémoire d'un ordinateur soit mise à jour, sans que les modifications soient répercutées dans les mémoires cache. Dans ce cas, le cache contient une donnée périmée. Or, un processeur doit toujours éviter de se retrouver avec une donnée périmée et doit toujours avoir la valeur correcte dans ses caches : cela s'appelle la '''cohérence des caches'''. Il est possible de se retrouver avec des valeurs périmées dans le cache sur les ordinateurs avec plusieurs processeurs, ou si un périphérique écrit en RAM, les modifications ne sont pas répercutées automatiquement dans les mémoires cache. Pour résoudre ce problème, on peut interdire de charger dans le cache des données stockées dans les zones de la mémoire dédiées aux périphériques. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires cache. Autre solution : utiliser le fait que les périphériques déclenchent une interruption matérielle pour laisser le contrôleur DMA accéder à la mémoire. Dans ce cas, il suffit de vider les caches à chaque interruption matérielle. Le processeur peut le faire automatiquement, ou fournir des instructions pour. ==Le ''cache bypassing'' : contourner le cache== Dans certaines situations, le cache n'est pas utilisé pour certains accès mémoire. Diverses techniques permettent en effet d'effectuer des accès mémoire qui contournent le cache, qui ne passent pas par le cache. Ils sont utilisés quand l'accès en cache fait que des instructions normales ne fonctionnent pas. Par exemple, de tels accès directs à la RAM sont notamment utilisés pour l'implémentation d'instructions atomiques, une classe d'instructions spécifiques utilisées sur les processeurs multicœurs, dont nous parlerons dans plusieurs chapitres. Mais ils sont aussi utilisés pour l'accès aux périphériques, ce que nous allons voir maintenant. ===Accéder aux périphériques demande de contourner le cache=== Pour rappel, un périphérique (au sens d'entrée-sortie) contient des registres d’interfaçage qui ont une adresse au même titre que les cases mémoire. Un périphérique peut à tout instant modifier ses registres d’interfaçage, ce qui se répercute automatiquement dans l'espace d'adressage, mais rien de tout cela n'est transmis au cache. Si les accès aux périphériques passaient par l'intermédiaire du cache, on aurait droit à des problèmes. On aurait encore une fois droit à des problèmes de cohérence des caches. Le problème est géré différemment suivant que l'on utilise un espace d'adressage séparé ou des entrées-sorties mappées en mémoire. La solution est que les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache. Cela demande d'adapter le cache et le processeur. L'implémentation exacte dépend de comment sont adressés les périphériques. Pour rappel, il y a deux solutions pour adresser les périphériques : soit les périphériques disposent d'un espace d'adressage séparé de celui de la mémoire, soit il y un espace d'adressage unique partagé entre processeur et mémoire. Les deux cas donnent des solutions différentes. Avec un espace d'adressage séparé, l'espace d'adressage des périphériques n'est pas caché : aucun accès dans cet espace d'adressage ne passe par le cache. La mémoire cache n'est utilisée que pour l'espace d'adressage des mémoires, rien d'autre. C'est de loin le cas le plus simple : il suffit de concevoir le processeur pour. Il dispose d'instructions séparées pour les accès aux registres d’interfaçage et à la RAM/ROM, les premières ne passent pas par le cache, les autres si. Avec des entrées-sorties mappées en mémoire, la même solution est utilisée, mais dans une version un peu différente. Là encore, les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache, si on veut qu'ils marchent comme ils le doivent. Cela demande d'adapter le cache et le matériel pour que accès aux périphériques mappés en mémoire contournent le cache. Des adresses, voire des zones entières de la mémoire, sont marquées comme étant non-cachables. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires caches. Là encore, le processeur doit être prévu pour : on doit pouvoir le configurer de manière à marquer certaines zones de la RAM comme non-cacheable. Reste qu'il faut marquer des régions de la RAM comme non-cacheable. Pour cela, on améliore les registres de configuration du cache, vus plus haut, afin qu'ils permettent de configurer certaines portions de la RAM pour préciser qu'elles ne doivent pas être mises en cache, qu'il faut activer le contournement de cache pour celles-ci. ===Contourner le cache pour des raisons de performance=== Il arrive que des données avec une faible localité soient chargées dans le cache inutilement. Or, il vaut mieux que ces données transitent directement entre le processeur et la mémoire, sans passer par l'intermédiaire du cache. Pour cela, le processeur peut fournir des instructions d'accès mémoire qui ne passent pas par le cache, à côté d'instructions normales. De telle instructions sont appelées des '''instructions mémoire non-temporelles'''. Non-temporelle, dans le sens : pas de localité temporelle (c.a.d que les données ne seront pas réutilisées plus tard). Mais il existe aussi des techniques matérielles, où le cache détecte à l'exécution les lectures qui gagnent à contourner le cache. La dernière méthode demande d'identifier les instructions à l'origine des défauts de cache, le processeur accédant directement à la RAM quand une telle instruction est détectée. Si une instruction d'accès mémoire fait trop de défauts de cache, c'est signe qu'elle gagne à contourner le cache. L'idée est de mémoriser, pour chaque instruction d'accès mémoire, un historique de ses défauts de cache. Il existe plusieurs méthodes pour cela, mais toutes demandent d'ajouter de quoi mémoriser l'historique des défauts de cache des instructions. L'historique est mémorisé dans une mémoire appelée la '''table d’historique des défauts de lecture''' (''load miss history table''), qui est souvent un cache. L'historique en question est, dans sa version la plus simple, un compteur de quelques bits incrémenté à chaque succès de cache et décrémenté à chaque défaut de cache, qui indique si l'instruction a en moyenne fait plus de défauts ou de succès de cache. La table associe le ''program counter'' d'une instruction mémoire à cet historique. À la première exécution d'une instruction d'accès mémoire, une entrée de cette table est réservée pour l'instruction. Lors des accès ultérieurs, le processeur récupérer les informations associées et décide s'il faut contourner le cache ou non. ==La hiérarchie mémoire des caches== [[File:Cache Hierarchy.png|vignette|Hiérarchie de caches]] On pourrait croire qu'un seul cache est largement suffisant pour compenser la lenteur de la mémoire. Hélas, les processeurs sont devenus tellement rapides que les caches sont eux-mêmes très lents ! Pour rappel, plus une mémoire peut contenir de données, plus elle est lente. Et les caches ne sont pas épargnés. Si on devait utiliser un seul cache, celui-ci serait très gros et donc trop lent. La situation qu'on cherche à éviter avec la mémoire RAM revient de plus belle. Même problème, même solution : si on a décidé de diviser la mémoire principale en plusieurs mémoires de taille et de vitesse différentes, on peut bien faire la même chose avec la mémoire cache. Depuis environ une vingtaine d'années, un processeur contient plusieurs caches de capacités très différentes : les caches L1, L2 et parfois un cache L3. Certains de ces caches sont petits, mais très rapides : c'est ceux auxquels on va accéder en priorité. Viennent ensuite d'autres caches, de taille variable, mais plus lents. Les processeurs ont donc une hiérarchie de caches qui se fait de plus en plus complexe avec le temps. Cette hiérarchie est composée de plusieurs niveaux de cache, qui vont des niveaux inférieurs proches de la mémoire RAM à des niveaux supérieurs proches du processeur. Plus on monte vers les niveaux supérieurs, plus les caches sont petits et rapides. Un accès mémoire dans une hiérarchie de cache fonctionne comme suit : on commence par vérifier si la donnée recherchée est dans le cache le plus rapide, à savoir le cache L1. Si c'est le cas,n on la charge depuis ce cache directement. Si elle n’y est pas, on vérifie si elle est dans le cache de niveau supérieur, le cache L2. Et rebelote ! Si elle n'y est pas, on vérifie le cache du niveau supérieur. Et on répète cette opération, jusqu’à avoir vérifié tous les caches. Si la donnée n'est dans aucun cache, on doit alors aller chercher la donnée en mémoire. [[File:Hiérarchie de caches.png|centre|vignette|upright=2|Hiérarchie de caches]] Il y a des différences assez notables entre chaque niveau de cache. Par exemple, les différents niveaux de cache n'ont pas forcément les mêmes politiques de remplacement des lignes de cache. Le cache L1 a généralement une politique de remplacement simple, très rapide, mais peu efficace. De même, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Le cache le plus proche de la mémoire est appelé le '''cache de dernier niveau''', ''Last Level Cache'' en anglais. Il a parfois des caractéristiques totalement différentes des autres caches. Par exemple, sur les processeurs multicoeurs, le cache L3 n'est techniquement pas dans le cœur, mais fait partie d'un ensemble de circuits reliés, comme le contrôleur mémoire ou l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc. ===Les caches exclusifs et inclusifs=== Notons que du point de vue de cette vérification, il faut distinguer les caches inclusifs et exclusifs. Avec les caches inclusifs, si une donnée est présente dans un cache, alors elle est présente dans les caches des niveaux inférieurs, ce qui implique l'existence de données en doublon dans plusieurs niveaux de cache. À l'opposé, les caches exclusifs font que toute donnée est présente dans un seul cache, pas les autres. Il existe aussi des caches qui ne sont ni inclusifs, ni exclusifs. Sur ces caches, chaque niveau de cache gère lui-même ses données, sans se préoccuper du contenu des autres caches. Pas besoin de mettre à jour les niveaux de cache antérieurs en cas de mise à jour de son contenu, ou en cas d'éviction d'une ligne de cache. La conception de tels caches est bien plus simple. Dans les '''caches exclusifs''', le contenu d'un cache n'est pas recopié dans le cache de niveau inférieur. Il n'y a pas de donnée en double et on utilise 100 % de la capacité du cache, ce qui améliore le taux de succès. Par contre, le temps d'accès est un peu plus long. La raison est que si une donnée n'est pas dans le cache L1, on doit vérifier l'intégralité du cache L2, puis du cache L3. De plus, assurer qu'une donnée n'est présente que dans un seul cache nécessite aux différents niveaux de caches de communiquer entre eux pour garantir que l'on a pas de copies en trop d'une ligne de cache, ce qui peut prendre du temps. [[File:Caches exclusifs.png|centre|vignette|upright=2|Caches exclusifs]] Dans le cas des '''caches inclusifs''', le contenu d'un cache est recopié dans les caches de niveau inférieur. Par exemple, le cache L1 est recopié dans le cache L2 et éventuellement dans le cache L3. Ce genre de cache a un avantage : le temps d'accès à une donnée est plus faible. La raison est qu'il ne faut pas vérifier tout un cache, mais seulement la partie qui ne contient pas de donnée en doublon. Par exemple, si la donnée voulue n'est pas dans le cache L1, on n'est pas obligé de vérifier la partie du cache L2 qui contient la copie du L1. Ainsi, pas besoin de vérifier certaines portions du cache, ce qui est plus rapide et permet de simplifier les circuits de vérification. En contrepartie, l'inclusion fait que qu'une partie du cache contient des copies inutiles, comme si le cache était plus petit. De plus, maintenir l'inclusion est compliqué et demande des circuits en plus et/ou des échanges de données entre caches. [[File:Caches inclusifs.png|centre|vignette|upright=2|Caches inclusifs]] Maintenir l'inclusion demande de respecter des contraintes assez fortes, ce qui ne se fait pas facilement. Premièrement, toute donnée chargée dans un cache doit aussi l'être dans les caches de niveau inférieur. Ensuite, quand une donnée est présente dans un cache, elle doit être maintenue dans les niveaux de cache inférieurs. De plus, toute donnée effacée d'un cache doit être effacée des niveaux de cache supérieurs : si une donnée quitte le cache L2, elle doit être effacée du L1. Ces trois contraintes posent des problèmes si chaque cache décide du remplacement des lignes de cache en utilisant un algorithme comme LRU, LFU, MRU, ou autre, qui utilise l'historique des accès. En effet, dans ce cas, le cache décide de remplacer les lignes de cache selon l'historique des accès, historique qui varie suivant chaque niveau de cache. Par exemple, une donnée rarement utilisée dans le L2 peut parfaitement être très fréquemment utilisée dans le L1 : la donnée sera alors remplacée dans le L2, mais sera maintenue dans le L1. On observe aussi des problèmes quand il existe plusieurs caches à un seul niveau : chaque cache peut remplacer les lignes de cache d'une manière indépendante des autres caches du même niveau, donnant lieu au même type de problème. Pour maintenir l'inclusion, les caches doivent se transmettre des informations qui permettent de maintenir l'inclusion. Par exemple, les caches de niveaux inférieurs doivent prévenir les niveaux de cache supérieurs quand ils remplacent une ligne de cache. De plus, toute mise à jour dans un cache doit être répercutée dans les niveaux de cache inférieurs et/ou supérieurs. On doit donc transférer des informations de mise à jour entre les différents niveaux de cache. Généralement, le contenu des caches d'instruction n'est pas inclus dans les caches de niveau inférieurs, afin d'éviter que les instructions et les données se marchent sur les pieds. Enfin, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Dans ce cas, l'inclusion est plus difficile à maintenir, pour des raisons assez techniques. ===Les caches eDRAM, sur la carte mère et autres=== D'ordinaire, les mémoires caches sont intégrées au processeur, à savoir que cache et CPU sont dans le même circuit imprimé. Les caches sont donc fabriqués avec de la SRAM, seule forme de mémoire qu'on peut implémenter dans un circuit intégré. Intégrer tous les caches dans le processeur est une solution et efficace. Mais certains processeurs ont procédé autrement. [[File:Cache-on-a-stick module.jpg|vignette|Cache-on-a-stick module]] Des processeurs assez anciens incorporaient un cache L1 dans le processeur, mais plaçaient un cache L2 sur la carte mère. Le cache était clippé sur un connecteur sur la carte mère, un peu comme le sont les barrettes de mémoire. On parlait alors de '''''Cache on a stick''''' (COAST). On aurait pu s'attendre à ce que de tels caches soient en DRAM, vu qu'ils sont placés sur des barrettes de RAM, mais la ressemblance avec la mémoire RAM principale s'arrête là. Le cache était fabriqué en mémoire SRAM, même s'il est en théorie possible de faire de tels caches avec de la DRAM. Les premiers processeurs avec un cache faisaient ainsi, au début des années 90. Il a été introduiot sur les processeurs Motorola, et a été utilisé sur les IBM PC et les Macintosh de l'époque. Les ordinateurs Macintosh utilisaient de tels caches, pour la pluaprt des modèles. Pour ce qui est des PC, les premiers processeurs x86 faisaient pareil, notamment les processeurs Intel. Le 486, le Pentium et le Pentium 2 utilisaient des ''Cache on a stick''. L'avantage est que cela permettait de mettre plus de cache, à une époque où les circuits étaient limités en transistors. De plus, cela permettait au consommateur de choisir quelle quantité de cache il voulait, selon ses finances. Il était possible de laisser le processeur fonctionner soit sans mémoire cache, soit avec un cache de 256 Kibioctets, de 512 Kibioctets, etc. Il était possible d'upgrader le cache si besoin. Pour les CPU Intel, le cache était connecté sur le bus système, au même titre que la mémoire RAM et les entrées-sorties. Il faut dire que les processeurs de l'époque utilisaient un bus système et n'avaient pas de bus mémoire dédié. Mais en théorie, rien n’empêche de connecter le cache sur un bus mémoire dédié. Toujours est-il que les lectures et écritures étaient propagées à la fois dans le cache et la RAM. Les écritures se faisaient dans les deux, systématiquement dans la RAM, mais aussi dans le cache en cas de succès de cache. Les lectures étaient servies soit par le cache en cas de succès de cache, soit par la RAM en cas de défaut de cache. Si le cache répondait en premier, la transaction sur le bus se terminait précocement et l'accès en RAM était abandonné. [[File:Intel486 Иерархия памяти.png|centre|vignette|upright=2.5|Intel486 : le cache était connecté sur le bus système.]] À l'inverse, certains processeurs possédaient un cache fabriqué en mémoire DRAM, et plus précisément avec de la mémoire eDRAM. Le cache n'était pas intégré dans le même circuit imprimé que le processeur, mais profitait d'une architecture en ''chiplet''. Pour rappel, cela veut dire que le processeur est en réalité composé de plusieurs circuits intégré séparés, mais interconnectés et soudés sur un même PCB carré. Avec un cache en eDRAM, le cache avait son propre circuit intégré, séparé du circuit intégré du processeur ou du circuit intégré pour le contrôleur mémoire/IO. Un exemple est celui du cache des processeurs Intel de microarchitecture Broadwell, vus dans ce chapitre dans la section sur les caches splittés. Les tags étaient intégrés dans le circuit intégré du processeur, mais les données étaient mémorisées dans une puce d'eDRAM séparée. La puce eDRAM correspondait en réalité à une DRAM adressable qui servait de DRAM pour les données et mémorisaient les voies du cache. ==Les caches adressés par somme et hashés== Les caches adressés par somme sont optimisés pour incorporer certains calculs d'adresse directement dans le cache lui-même. Pour rappel, certains modes d'adressage impliquent un calcul d'adresse, qui ajoute une constante à une adresse de base. Généralement, l'adresse de base est l'adresse d'un tableau ou d'une structure, et la constante ajoutée indique la position de la donnée dans le tableau/la structure. Les caches hashés et les caches adressés par somme permettent de faire l'addition directement dans la mémoire cache. Voyons d'abord les caches hashés, avant de passer aux caches adressés par somme. Sur les '''caches hashés''', l'addition est remplacée par une autre opération, par exemple des opérations bit à bit du style XOR, AND ou OR, etc. Seulement, utiliser des opérations bit à bit pose un problème : il arrive que deux couples Adresse/décalage donnent le même résultat. Par exemple, le couple Adresse/décalage 11101111/0001 donnera la même adresse que le couple 11110000/0000. Dit autrement, deux adresses censées être différentes (après application du décalage) sont en réalité attribuées à la même ligne de cache. Il est toutefois possible de gérer ces situations, mais cela demande des astuces de haute volée pour faire fonctionner la mémoire cache correctement. Sur les '''caches adressés par somme''', le décodeur est modifié pour se passer de l'addition. Pour comprendre comment, il faut rappeler qu'un décodeur normal est composé de comparateurs, qui vérifient si l'entrée est égale à une constante bien précise. Sur un cache ordinaire, l'addition est faite séparément du décodage des adresses par le cache, dans l'unité de calcul ou dans l'unité de génération d'adresse. [[File:Non sum adressed cache.png|centre|vignette|upright=2|Cache normal.]] Mais les caches adressés par somme modifient le décodeur, qui est alors composé de comparateurs qui testent si la somme adresse + décalage est égale à une constante. [[File:Cache adressé par somme.png|centre|vignette|upright=2|Cache adressé par somme.]] Chaque circuit du décodeur fait le test suivant, avec K une constante qui dépend du circuit : : <math>A + B = K</math> Ce qui est équivalent à faire le test suivant : : <math>A + B - K = 0</math> En complément à deux, on a <math>- K = \overline{K} + 1</math>. En injectant dans l'équation précédente, on a : : <math>A + B + \overline{K} + 1 = 0</math> En réorganisant les termes, on a : : <math>A + B + \overline{K} = - 1</math> Il suffit d'utiliser un additionneur ''carry-save'' pour faire l'addition des trois termes. Rappelons qu'un tel additionneur fournit deux résultats en sortie : une somme calculée sans propager les retenues et les retenues en question. Notons que les retenues sont à décaler d'un cran, vu qu'elles sont censées s'appliquer à la colonne suivante. En notant la somme S et les retenues R, on a: : <math>S + (R << 1) = - 1 </math>, le décalage d'un cran à gauche étant noté <math><< 1</math>. Ensuite, -1 est codé avec un nombre dont tous les bits sont à 1 en complément à un/deux. : <math>S + (R << 1) = 111 \cdots 111111</math> [[File:Sum + retenue add.png|centre|vignette|upright=2|Sum + retenue add]] Un simple raisonnement nous permet de savoir si le résultat est bien -1, sans faire l'addition <math>S + (R << 1)</math>. En effet, on ne peut obtenir -1 que si la somme est l'inverse des retenues : un 0 dans le premier nombre correspond à un 1 dans l'autre, et réciproquement. En clair, on doit avoir <math>\overline{S} = R << 1</math>. Pour vérifier cela, il suffit de faire un simple XOR entre la somme et les retenues décalées d'un cran. On a alors : : <math>S \oplus (R << 1) = 111 \cdots 111111</math> La comparaison avec -1 se fait avec une porte ET à plusieurs entrées. En effet, la porte donnera un 1 seulement si tous les bits d'entrée sont à 1, ce qui est ce qu'on veut tester. Au final, l'additionneur pour l'addition adresse + décalage est remplacé par un additionneur carry-save suivi d'une couche de portes XOR et d'un comparateur avec une constante, ce qui économise de circuits et améliore les performances. [[File:Final circuit of sum addressed cache.png|centre|vignette|upright=2|Cache adressé par somme.]] En prenant en compte que la constante K est justement une constante, certaines entrées de l'additionneur carry-save sont toujours à 0 ou à 1, ce qui permet quelques simplifications à grand coup d’algèbre de Boole. Chaque additionneur complet qui compose l’additionneur carry-save est remplacée par des demi-additionneurs (ou par un circuit similaire). Autant dire que l'on gagne tout de même un petit peu en rapidité, en supprimant une couche de portes logiques. Le circuit de décodage économise aussi des portes logiques, ce qui est appréciable. ==Les caches à accès uniforme et non-uniforme== Intuitivement, le temps d'accès au cache est le même pour toutes les lignes de cache. Il s'agit de cache appelés '''caches à accès uniforme''', sous-entendu à temps d'accès uniforme. Mais sur les caches de grande capacité, il arrive souvent que le temps de propagation des signaux varie fortement suivant la ligne de cache à lire. D'ordinaire, on se cale sur la ligne de cache la plus lente pour caler la fréquence d'horloge du cache, même si on pourrait faire mieux. Cependant, les '''caches à accès non uniforme''' ont une latence différente pour chaque ligne d'un même cache. Certaines lignes de cache sont plus rapides que d'autres. Niveau terminologie, nous allons parler de caches UCA et NUCA : ''Uniform Access Cache'' pour les caches à accès uniforme, ''Non-Uniform Access Cache'' pour les caches à accès non-uniforme. [[File:Caches UCA et NUCA.png|vignette|Caches UCA et NUCA.]] Les caches NUCA et UCA sont souvent composés de plusieurs banques séparées, typiquement une par voie. Sur les caches UCA, les banques sont interconnectées avec le processeur de manière à ce que toutes les interconnexions ont la même longueur pour toutes les banques. Typiquement, les banques sont organisées en carré, avec les interconnexions qui partent du centre, avec une disposition en H, illustrée ci-contre Mais avec les caches NUCA, ce n'est pas le cas. Les interconnexions sont simplifiées et ont des longueurs différentes. Les caches NUCA n'ont pas tous le même genre d'interconnexions, qui dépendent du cache NUCA. En général, les interconnexion forme un réseau avec des sortes de routeurs qui redirigent les données/commandes vers la bonne destination : cache ou processeur. Les banques plus proches du processeur sont accessibles plus rapidement que celles éloignées, même si la différence n'est pas énorme. Les caches NUCA sont généralement associatifs par voie. Les plus simples utilisent une banque par voie pour le cache, ce qui fait que certaines voies répondent plus vite que les autres. La détection des succès de cache est alors plus rapide si la donnée lue/écrite est dans une voie/banque rapide. En théorie, les défauts de cache demandent de vérifier toutes les banques, et se calent donc sur la pire latence. Mais divers caches se débrouillent pour que ce ne soit pas le cas, soit en vérifiant les banquyes unes par une, soit par un mécanisme de recherche plus complexe. Les caches NUCA sont surtout utilisés pour les caches L3 et L4, éventuellement les caches L2. Les caches L1 sont systématiquement des caches UCA, car la latence de l'accès au cache L1 est utilisée par le processeur pour décider quand lancer les instructions. Pour simplifier, le processeur peut démarrer en avance une instruction avant qu'une opérande soit lue dans le cache L1, de manière à ce que la donnée arrive en entrée de l'ALU pile en même temps que l'instruction. Une histoire d'exécution dans le désordre et d'émission anticipée des instructions qu'on détaillera dans une bonne dizaine de chapitres. Toujours est-il que tout est plus simple pour le processeur si le cache L1 a un temps d'accès fixe. Par contre, les caches L3 et L4 sont traités en attendant que les données arrivent, le processeur reprend l'exécution des instructions quand les caches L3 et L4 ont terminé de répondre, pas avant. Avec l'association une banque = une voie, la correspondance ligne de cache → bloc de mémoire qui est statique : on ne peut pas déplacer le contenu d'une ligne de cache dans une autre portion de mémoire plus rapide suivant les besoins. Mais la recherche académique a étudié le cas où la correspondance entre une ligne de cache et une banque varie à l’exécution. Pour nommer cette distinction, on parle de caches S-NUCA (''Static NUCA'') et D-NUCA (''Dynamic NUCA''). Intuitivement, on s'attend à ce que les caches D-NUCA soient plus performants que les caches S-NUCA. Les lignes de cache les plus utilisées peuvent migrer dans une banque rapide, alors que les lignes de cache moins utilisées vont dans une banque éloignée. Les lignes de cache se répartissent dans le cache dynamiquement dans les banques où elles sont le plus adaptées. Mais paradoxalement, le gain des caches D-NUCA est presque nul, voire insignifiant. La raison est que les caches D-NUCA doivent incorporer un système pour déterminer dans quelle banque se situe la donnée pour détecter les succès/défauts de cache, ainsi qu'un système pour migrer les données entre banques. Et ce système augmente le temps d'accès au cache, réduisant à néant l'intérêt d'un cache D-NUCA. Si on économise quelques microsecondes de temps d'accès en passant d'un cache UCA à un cache S-NUCA, ce n'est pas pour les perdre en passant à un D-NUCA. La majorité des caches D-NUCA sont donc en cours de recherche, mais ne sont pas utilisés en pratique. ==La tolérance aux erreurs des caches== Une mémoire cache reste avant tout une mémoire RAM, bien que ce soit de la SRAM. Elle n'est pas parfaite et est donc sujette à des erreurs, qui peuvent inverser un bit ou l'effacer. De telles erreurs sont liées à des rayons cosmiques très énergétiques, à des particules alpha produites par le packaging ou le métal deu circuit intégré, peu importe : l'essentiel est qu'ils inversent parfois un bit. Les mémoires modernes savent se protéger contre de telles erreurs, en utilisant trois moyens. ===Les mémoires caches ECC et à bit de parité=== Le premier moyen est l'usage de codes correcteurs d'erreurs, qui ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Les bits ajoutés dépendent de la donnée mémorisée dans le byte, et servent à détecter une erreur, éventuellement à la corriger. Le cas le plus simple ajoute un simple bit de parité pour chaque byte et se contente de détecter les erreurs dans les corriger. Les autres codes ECC permettent eux de corriger des erreurs, mais ils demandent d'ajouter au moins deux bits par byte, ce qui a un cout en circuit plus élevé. Un simple bit de parité permet de détecter qu'un bit a été inversé, mais ne permet pas de corriger l'erreur. En soi, ce n'est pas un problème. Si une erreur est détectée, on considère que la ligne de cache est invalide. Le cache gère la situation comme un défaut de cache et va chercher la donnée valide en mémoire RAM. Le cout en circuits est donc faible, mais les défauts de cache sont plus nombreux. Les codes ECC sont eux capables de corriger les erreurs, si elles ne modifient pas trop de bits d'un coup. Par contre, ils utilisent deux à trois bits par octet, ce qui a un cout en circuits loin d'être négligeable. Il y a donc un compromis entre défauts de cache et cout en circuits. La gestion de l'ECC est différente suivant le niveau de cache. Généralement, le cache L1 n'utilise pas l'ECC mais se contente d'un simple bit de parité pour éviter la corruption de ses données. Le cache étant petit, les corruptions de données sont assez rares, et les défauts de cache induits faibles. Il est plus important d'utiliser un code de détection d'erreur simple, rapide, qui ne ralentit pas le cache et n'augmente pas sa latence. Si une ligne de cache est corrompue, il a juste à aller lire la ligne depuis le cache L2, ou un niveau de cache inférieur. Du moins, c'est possible sur le cache en question est un cache inclusif et/ou ''write-through''. Par contre, le niveau de cache L2 et ceux en-dessous utilisent presque systématiquement une mémoire SRAM ECC. La raison principale étant que ce sont des caches assez gros, pour lesquels la probabilité d'une erreur est assez élevée. Plus une mémoire a de bits et prend de la place, plus il y a une chance élevée qu'un bit s'inverse. Et vu que les caches L2/L3/L4 sont par nature plus lents et plus gros, ils peuvent se permettre le cout en performance lié à l'ECC, idem pour le cout en circuit. Sans compter qu'en cas d'erreur, ils doivent aller lire la ligne de cache originelle en mémoire RAM, ce qui est très lent ! Mieux vaut corriger l'erreur sur place en utilisant l'ECC. ===L'usage du ''memory scrubbing'' sur les caches=== La plupart des erreurs ne changent qu'un seul bit dans un byte, mais le problème est que ces erreurs s'accumulent. Entre deux accès à une ligne de cache, il se peut que plusieurs erreurs se soient accumulées, ce qui dépasse les capacités de correction de l'ECC. Dans ce cas, il existe une solution appelée le ''memory scrubbing'', qui permet de résoudre le problème au prix d'un certain cout en performance. Pour rappel, l'idée est de vérifier les lignes de caches régulièrement, pour éviter que les erreurs s'accumulent. Par exemple, on peut vérifier chaque ligne de cache toutes les N millisecondes, et corriger une éventuelle erreur lors de cette vérification. En faisant des vérifications régulières, on garantir que les erreurs n'ont pas le temps de s'accumuler, sauf en cas de malchance avec des erreurs très proches dans le temps. Il ne s'agit pas d'un rafraichissement mémoire, car les SRAM ne s'effacent pas), mais ça a un effet similaire. Et évidemment, le ''memory scrubbing'' a un cout en performance. On peut faire une comparaison avec le rafraichissement mémoire : les rafraichissement réguliers réduisent les performances, car cela fait des accès en plus. Des accès qui sont de plus timés à des instants bien précis qui ne sont pas forcément les plus adéquats. Il est possible qu'un rafraichissement ait lieu en même temps qu'un accès mémoire et le rafraichissement a la priorité, ce qui réduit les performances. La même chose arrive avec les vérifications du ''memory scrubbing''. Malgré tout, la technique a été utilisée sur les caches de certains processeurs commerciaux, dont des processeurs AMD Athlon et Athlon 64. Elle est surtout utilisable sur les caches L2/L3, pour lesquels le cout du pseudo-rafraichissement est acceptable. ==Un exemple de cache : le cache d'instruction== La grande majorité des processeurs utilise deux caches L1 séparés : un '''cache d'instructions''' dédié aux instructions, et un autre pour les données. Une telle organisation permet de charger une instruction tout en lisant une donnée en même temps. Notons que seul le cache L1 est ainsi séparé entre cache de données et d'instructions. Le cache d’instruction se situe en théorie entre l'unité de chargement et l'unité de décodage. En effet, ce cache prend en entrée une adresse et fournit une instruction. L'adresse est fournie par le ''program counter'', l'instruction est envoyée dans l'unité de décodage. Le cache se situe donc entre les deux. Le cache de données L1 est connecté au chemin de données, et notamment aux unités de communication avec la mémoire, pas au séquenceur. [[File:Caches L1 et positions dans le processeur.png|centre|vignette|upright=2.5|Caches L1 et positions dans le processeur]] Les deux caches sont reliés au processeur par des bus séparés, l'ensemble ressemble à une architecture Harvard, mais où les caches remplacent les mémoires RAM/ROM. Le cache d'instruction prend la place de la mémoire ROM et le cache de données prend la place de la mémoire RAM. Évidemment, il y a des niveaux de caches en dessous des caches de données/instruction, et ceux-ci contiennent à la fois données et instructions, les deux ne sont pas séparées dans des mémoires/caches séparés. Raison pour laquelle l'ensemble est appelé une '''architecture Harvard modifiée'''. Architecture Harvard, car l'accès aux données et instructions se font par des voies séparées pour le processeur, modifiée car la séparation n'est effective que pour le cache L1 et pas les autres niveaux de cache, et encore moins la RAM. Sur les processeurs modernes, il arrive très souvent que le processeur doive charger une instruction et lire/écrire une donnée en même temps. Et à vrai dire, c'est la règle plus que l'exception. L'usage d'une architecture Harvard modifiée permet cela très facilement : on peut accéder au cache d'instruction via un bus, et au cache de donnée avec l'autre ===Pourquoi scinder le cache L1 en cache d'instruction et de données=== L'usage d'un cache d’instruction séparé du cache de données est à contraster avec l'usage d'un cache L1 multiport unique, capable de mémoriser à la fois instructions et données. Les deux solutions sont possibles ont été utilisées. Les premiers processeurs avaient un cache L1 unique et multiport, mais ce n'est plus le cas sur les processeurs modernes, car les contraintes ne sont pas les mêmes. Le compromis à faire est celui entre deux petits caches rapides et un gros cache plus lent. Pour rappel, plus un cache est petit, plus il est rapide et chauffe moins. Donc au lieu d'utiliser, par exemple, un gros cache lent de 64 Kibioctets, on utilise deux caches de 32 kibioctets, plus rapides. La capacité totale est la même, mais le temps d'accès plus faible. En termes de temps d'accès, la meilleure solution est celle des deux caches simple port. Mais pour ce qui est de l'économie de circuits, c'est moins évident. Entre deux mémoires simple port et une mémoire multiport, la différence en termes de transistors est ambigüe et dépend de la capacité des caches. La différence est surtout notable pour les gros caches, moins pour les petits caches. Il faut aussi tenir compte de la capacité effective. Avec deux caches séparés, la répartition de la capacité du cache L1 est fixée une bonne fois pour toutes. Par exemple, avec un cache d'instruction de 32 KB et un cache de données de 32 KB, impossible d'allouer 40 KB aux données et 20 aux instructions. Alors qu'avec un cache L1 unique de 64 KB, on pourrait le faire sans soucis. La répartition se fait naturellement, en fonction de la politique de remplacement du cache et est proche de l'optimal. C'est là un désavantage des caches d'instructions/données séparés : une capacité effective moindre. Tout cela explique pourquoi le cache L1 est le seul à être ainsi scindé en deux, avec une séparation entre instructions et données : les contraintes au niveau du cache L1 et L2 ne sont pas les mêmes. Pour les caches L1, le temps d'accès est plus important que la capacité, ce qui favorise les caches séparés. Par contre, pour les caches L2/L3/L4, le temps d'accès n'est pas déterminant, alors que la capacité effective et l'économie en circuits sont significatives. ===La connexion des caches L1 avec le cache L2=== Pour les connexions avec le cache L2, tout dépend du processeur. Certains utilisent un cache L2 multiport, qui permet aux deux caches L1 de lire ou écrire dans le cache L2 simultanément. [[File:Cache d'instructions.png|centre|vignette|upright=1.5|Cache d'instructions.]] Si le cache L2 ne gère pas les accès simultanés, il n'y a qu'un seul bus relié aux caches L1 et au cache L2. On doit effectuer un arbitrage pour décider quel cache a la priorité, chose qui est réalisé par un circuit d'arbitrage spécialisé. [[File:Circuit d'arbitrage du cache.png|centre|vignette|upright=1.5|Circuit d'arbitrage du cache.]] Généralement, les caches d'instructions peuvent se permettre d'être plus petits que les caches de données, car les programmes sont souvent plus petits que les données manipulées. Songez que des programmes de quelques mébioctets peuvent parfois remplir la RAM avec plusieurs gibioctets de données. Lancez votre navigateur internet et ouvrez une page web un peu chargée, pour vous en convaincre ! ===Les spécificités du cache d'instruction : lecture seule, bloquant, etc=== Les instructions sont rarement modifiées ou accédées en écritures, contrairement aux données. Et cela permet d'utiliser un cache simplifié pour les instructions. Autant un cache généraliste doit permettre les lectures et écritures depuis le processeur (avec les échanges avec la RAM), autant un cache d'instruction peut se contenter des lectures provenant du CPU et des échanges avec la RAM. Le cache d'instructions est donc très souvent en « lecture seule » : le processeur ne peut pas écrire dedans, mais juste le lire ou charger des instructions dedans. Un cache d'instruction est donc plus simple qu'un cache pour les données : on peut retirer les circuits en charge de l'écriture (mais on doit laisser un port d'écriture pour charger les instructions dedans). Le gain en circuits permet d'utiliser un cache d'instruction plus gros ou au contraire de laisser de la place pour le cache de données. Le gain en termes de capacité compense alors un peu les inconvénients des caches séparés. Par contre, cela complique la gestion du code automodifiant, c'est-à-dire des programmes dont certaines instructions vont aller en modifier d'autres, ce qui sert pour faire de l'optimisation ou est utilisé pour compresser ou cacher un programme (les virus informatiques utilisent beaucoup de genre de procédés). Quand le processeur exécute ce genre de code, il ne peut pas écrire dans ce cache L1 d'instructions, mais doit écrire dans le cache L2 ou en RAM, avant de recharger les instructions modifiées dans le cache L1. Cela qui prend du temps et peut parfois donner lieu à des erreurs si le cache L1 n'est pas mis à jour. Les algorithmes de remplacement des lignes de cache optimaux pour les données ne le sont pas pour les instructions, de même que la taille optimale du cache, la taille des lignes de cache optimale, ou même les algorithmes de préchargement. Par exemple, pour le remplacement des lignes de cache, un simple algorithme LRU est presque optimal pour les instructions, autant il peut donner de mauvaises performances quand on manipule beaucoup de tableaux. Cela justifie d'utiliser des caches spécialisés pour chacune. On peut adapter le cache d'instruction à son contenu, ce qui le rend plus rapide ou plus petit à performance égale. Les caches d'instructions sont généralement des caches bloquants. Il ne servirait à rien de rendre un cache d'instruction non-bloquant, le cout en circuits ne se traduirait pas par une augmentation significative des performances. À l'opposé, les caches de données sont non-bloquants sur les architectures modernes, pour des raisons de performance. Ce qui rend la séparation assez intéressante, les deux caches ayant des besoins différents et des implémentations différentes, cela permet d'optimiser le cout en transistors des caches. ===L'impact du cache d'instruction sur les performances=== Sur les architectures conventionnelles, le cache d'instruction a plus d'impact sur les performances que le cache de données. La raison principale est que les instructions ont une meilleure localité spatiale et temporelle que pour les données. Pour la localité spatiale, les instructions consécutives se suivent en mémoire, alors que rien ne garantit que des données utilisées ensemble soient regroupées en mémoire. Pour localité temporelle, elle est très variable pour les données, mais très courante pour les instructions du fait de l'usage fréquent des boucles et des fonctions. : La présence de branchements atténue la localité temporelle des instruction, sauf que la majorité des branchements sautent à un endroit très proche, seuls les appels de fonction brisent la localité spatiale. La conséquence est qu'il arrive que certains CPU aient un cache L1 d'instruction plus gros que celui pour les données. On parle alors de '''cache L1 asymétriques'''. Un exemple est celui des processeurs AMD de microarchitecture Zen, dont le cache d'instruction était deux fois plus gros que le cache de données. Leur cache d'instruction faisait 64 kibioctets, contre seulement 32 pour le cache de données. D'ailleurs, il existe des processeurs assez extrêmes qui se contentent d'un cache d'instruction unique, sans cache de données. C'est le cas sur les processeurs vectoriels ou les GPU que nous verrons dans les chapitres de fin de ce wikilivres. De tels processeurs sont spécialisés dans la manipulation de tableaux de données, traitement qui a une faible localité temporelle. En conséquence, utiliser un cache de données n'est pas vraiment utile, voire peu être contreproductif, alors qu'un cache d’instruction fonctionne parfaitement. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Compléments sur les mémoires de masse | prevText=Compléments sur les mémoires de masse | next=Le préchargement | nextText=Le préchargement }} </noinclude> mcmv9n4qzeww8jw9l1uoi5zomfojbmo 765123 765122 2026-04-26T15:36:28Z Mewtow 31375 /* L'exemple des processeurs 386 et du contrôleur de cache 82385 */ 765123 wikitext text/x-wiki Le cache est une mémoire intercalée entre la mémoire et un processeur, plus rarement à l'intérieur d'un périphérique. Il est souvent fabriquée avec de la mémoire SRAM, parfois avec de l'eDRAM. Sans lui, on se croirait à l'âge de pierre tellement nos PC seraient lents ! En effet, la mémoire est très lente comparée au processeur. Le temps mis pour accéder à la mémoire est du temps durant lequel le processeur n'exécute pas d'instruction (sauf cas particuliers impliquant un pipeline). Pour diminuer ce temps d'attente, il a été décidé d'intercaler une mémoire petite mais rapide, entre le processeur et la mémoire. Ainsi, le processeur accède à un cache très rapide plutôt qu'à une RAM beaucoup plus lente. ==L'accès au cache== Le cache contient une copie de certaines données présentes en RAM. La copie présente dans le cache est accessible bien plus rapidement que celle en RAM, vu que le cache est plus rapide. Mais seule une petite partie de ces données sont copiées dans le cache, les autres données devant être lues ou écrites dans la RAM. Toujours est-il que le cache contient une copie des dernières données accédées par le processeur. Une donnée est copiée dans la mémoire cache quand elle est lue ou écrite par le processeur. Le processeur conserve une copie de la donnée dans le cache après son premier accès. Les lectures/écritures suivantes se feront alors directement dans le cache. Évidemment, au fur et à mesure des accès, certaines données anciennes sont éliminées du cache pour faire de la place aux nouveaux entrants, comme nous le verrons plus tard. [[File:Principe d'une mémoire cache.gif|centre|vignette|upright=2|Principe d'une mémoire cache.]] La mémoire cache est invisible pour le programmeur, qui ne peut pas déceler celles-ci dans l'assembleur. Les accès mémoire se font de la même manière avec ou sans le cache. La raison à cela est que le cache intercepte les accès mémoire et y répond s'il en a la capacité. Par exemple, si le cache intercepte une lecture à une adresse et que le contenu de cette adresse est dans le cache, le cache va outrepasser la mémoire RAM et la donnée sera envoyée par le cache au lieu d'être lue en RAM. par contre, si un accès se fait à une adresse pour laquelle le cache n'a pas la donnée, alors l'accès mémoire sera effectué par la RAM de la même manière que si le cache n'était pas là. [[File:Accès au cache.png|centre|vignette|upright=2|Accès au cache]] ===Les succès et défauts de caches=== Tout accès mémoire est intercepté par le cache, qui vérifie si la donnée demandée est présente ou non dans le cache. Si la donnée voulue est présente dans le cache, on a un '''succès de cache''' (''cache hit'') et on accède à la donnée depuis le cache. Sinon, c'est un '''défaut de cache''' (''cache miss'') et on est obligé d’accéder à la RAM. Les défauts de cache peuvent avoir plusieurs origines. Tout ce qu'il faut savoir est que lorsque le processeur accède à une donnée ou une instruction pour la première fois, il la place dans la mémoire cache car elle a de bonnes chances d'être réutilisée prochainement. La raison à cela est qu'un programme a tendance à réutiliser les instructions et données qui ont été accédées dans le passé : c'est le ''principe de localité temporelle''. Bien évidement, cela dépend du programme, de la façon dont celui-ci est programmé et accède à ses données et du traitement qu'il fait, mais c'est souvent vrai en général. La première cause des défauts de cache est liée à la taille du cache. À force de charger des données/instructions dans le cache, le cache fini par être trop petit pour conserver les anciennes données. Le cache doit bien finir par faire de la place en supprimant les anciennes données, qui ont peu de chances d'être réutilisées. Ces anciennes données éliminées du cache peuvent cependant être accédées plus tard. Tout prochain accès à cette donnée mènera à un cache miss. C'est ce qu'on appelle un ''Capacity Cache Miss'', ou encore '''défaut de capacité'''. Les seules solutions pour éviter cela consistent à augmenter la taille du cache ou à optimiser le programme exécuté (voir plus bas). Une autre raison pour un défaut est donc la suivante. Lorsqu'on exécute à une instruction ou qu'on accède à donnée pour la première fois, celle-ci n'a pas encore été chargée dans le cache. Le défaut de cache est inévitable : ce genre de cache miss s'appelle un ''Cold Miss'', ou encore un '''défaut à froid'''. De tels défauts sont presque impossibles à éliminer, sauf à utiliser des techniques de préchargement qui chargent à l'avance des données potentiellement utiles. Ces méthodes de préchargement se basent sur le principe de localité spatiale, à savoir le fait que les programmes ont tendance à accéder à des données proches en mémoire. Pour donner un exemple, les instructions d'un programme sont placées en mémoire dans l’ordre dans lequel on les exécute : la prochaine instruction à exécuter est souvent placée juste après l'instruction en cours (sauf avec les branchements). Quand on accède à une donnée ou une instruction, le cache peut précharger les données adjacentes pour en profiter. Nous parlerons de ces techniques de préchargement dans un chapitre dédié, vers la fin du cours. ===Le fonctionnement du cache, vu du processeur=== Vu du processeur, le cache prend en entrée toutes les informations nécessaires pour effectuer un accès mémoire : des signaux de commande, une adresse et la donnée à écrire si besoin. Tout cela est passé en entrée du cache, celui-ci répondant aux accès mémoire via divers bits de contrôles, que le processeur peut lire à souhait. Le cache fournit aussi la donnée à lire, pour les lectures, sur une sortie, connectée directement au bus mémoire/processeur. Globalement, le cache a une capacité limitée, mais il prend en entrée des adresses complètes. Par exemple, sur un processeur 64 bits, le cache prend en entrée des adresses de 64 bits (sauf si optimisations), même si le cache en question ne fait que quelques mébioctets. Les caches sont souvent des mémoires multiports, surtout sur les processeurs récents. Les caches simple port sont rares, mêmes s'ils existent et ont existé par le passé. les caches double port sont eux plus fréquents, et ont généralement un port d'écriture séparé du port de lecture. Mais les caches récents ont plusieurs ports de lecture/écriture et sont capables de gérer plusieurs accès mémoire simultanés. Les données présentes dans le cache sont (pré)chargées depuis la mémoire, ce qui fait que toute donnée dans le cache est la copie d'une donnée en mémoire RAM. Le cache doit faire la correspondance entre une donnée du cache et l'adresse mémoire correspondante. Du point de vue du fonctionnement, on peut voir le cache comme une sorte de table de correspondance, qui mémorise des données, chacune étant associée à son adresse mémoire. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Cela vaut du point de vue du processeur, le fonctionnement interne du cache étant quelque peu différent selon le cache. Il existe des caches dont le fonctionnement interne est bien celui d'une table de correspondance matérielle, d'autres qui sont beaucoup plus optimisés. [[File:Fonctionnement d'une mémoire associative à correspondance.png|centre|vignette|upright=2|Fonctionnement simplifié d'une mémoire cache : les adresses sont dans la colonne de gauche, les données sont dans la colonne de droite. On voit qu'on envoie l'adresse au cache, que celui-ci répond en renvoyant la donnée associée.]] ==La performance des mémoires caches== L'analyse de la performance des mémoires caches est plus riche pour celle des autres mémoires. Sa performance dépend de beaucoup de paramètres, mais on peut cependant citer les principaux. Les deux premiers sont tout bonnement sa latence et son débit, comme pour n'importe quelle autre mémoire. La latence est plus importante que son débit, car le processeur est généralement plus rapide que le cache et qu'il n'aime pas attendre. Mais le critère le plus important pour un cache est sa capacité à empêcher des accès mémoire, son efficacité. Plus les accès mémoire sont servis par le cache au lieu de la RAM, meilleures seront les performances. Pour résumer, la performance d'un cache est surtout caractérisée par deux métriques : le taux de défaut, qui correspond à l’efficacité du cache, et la latence du cache. ===Le taux de succès/défaut=== Le '''taux de succès''' (hit ratio) est un premier indicateur des performances du cache, mais un indicateur assez imparfait. C'est le pourcentage d'accès mémoire qui ne déclenchent pas de défaut de cache. Plus il est élevé, plus le processeur accède au cache à la place de la RAM et plus le cache est efficace. Certains chercheurs préfèrent utiliser le '''taux de défauts''', à savoir le pourcentage d'accès mémoire qui entraînent un défaut de cache. Plus il est bas, meilleures sont les performances. Le taux de défaut est relié au taux de succès par l'équation <math>T_\text{succes} = 1 - T_\text{defaut}</math>. Par définition, il est égal à : : <math>\text{Taux de défauts de cache} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d’accès mémoires}}</math> Plutôt que de comparer le nombre de défauts/succès de cache au nombre d'accès mémoire, il est aussi possible de diviser le nombre de défauts par le nombre total d'instructions. On obtient alors le '''taux de défauts/succès par instruction''', une autre métrique utile. Par définition, elle est égale à : : <math>\text{Taux de défauts par instruction} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d'instructions}} = \text{Taux de défauts de cache} \times \frac{\text{Nombre d’accès mémoires}}{\text{Nombre d'instructions}}</math> Si certains défauts de cache sont inévitables quel que soit le cache, comme les défauts à froids, mentionnés plus haut, d'autres défauts peuvent être évités en augmentant la capacité du cache. C'est le cas des défauts de capacité qui sont causés par un accès à une donnée qui a été éliminée du cache faute de place. Plus le cache est gros, moins il a de chances d'être rempli, moins il doit rapatrier de données, plus son taux de succès augmente. Mais nous reviendrons sur le lien entre taille du cache et taux de défaut plus bas. Le taux de succès ne dépend pas que du cache, mais aussi de la conception des programmes exécutés. Une bonne utilisation du cache (ainsi que de la mémoire virtuelle) repose sur le programmeur qui doit prendre en compte les principes de localités dès la conception de ses programmes. Par exemple, un programmeur peut parfaitement tenir compte du cache au niveau de son algorithme : on peut citer l'existence des algorithmes ''cache oblivious'', qui sont conçus pour être optimaux quelle que soit la taille du cache. Le programmeur peut aussi choisir ses structures de données de manière à améliorer la localité. Par exemple, un tableau est une structure de donnée respectant le principe de localité spatiale, tandis qu'une liste chaînée ou un arbre n'en sont pas (bien qu'on puisse les implémenter de façon à limiter la casse). D'autres optimisations sont parfois possibles : par exemple, le sens de parcours d'un tableau multidimensionnel peut faire une grosse différence. Cela permet des gains très intéressants pouvant se mesurer avec des nombres à deux ou trois chiffres. Je vous recommande, si vous êtes programmeur, de vous renseigner le plus possible sur les optimisations de code ou algorithmiques qui concernent le cache : il vous suffira de chercher sur Google. Il y a une citation qui résume bien cela, prononcée par un certain Terje Mathisen. Si vous ne le connaissez pas, cet homme est un vieux programmeur (du temps durant lequel on codait encore en assembleur), grand gourou de l’optimisation, qui a notamment travaillé sur le moteur de Quake 3 Arena. {{BlocCitation|Almost all programming can be viewed as an exercise in caching.|auteur=Terje Mathisen}} ===La latence moyenne d'un cache=== Le temps mis pour lire ou écrire une donnée varie en présence d'un cache. Certaines lectures/écritures vont atterrir directement dans le cache (succès) tandis que d'autres devront aller chercher leur contenu en mémoire RAM (défaut de cache). Dans tous les cas, qu'il y ait défaut ou non, le cache sera consulté et mettra un certain temps à répondre, égal au temps de latence du cache. Tous les accès mémoires auront donc une durée au moins égale au temps de latence du cache, qui sera notée <math>T_c</math>. En cas de succès, le cache aura effectué la lecture ou l'écriture, et aucune action supplémentaire n'est requise. Ce qui n'est pas le cas en cas de défaut : le processeur devra aller lire/écrire la donnée en RAM, ce qui prend un temps supplémentaire égal au temps de latence de la mémoire RAM. Un défaut ajoute donc un temps, une pénalité, à l'accès mémoire. Dans ce qui suivra, le temps d'accès à la RAM sera noté <math>T_m</math>. Fort de ces informations, nous pouvons calculer le temps de latence moyen d'un accès mémoire, qui est la somme du temps d'accès au cache (pour tous les accès mémoire), multiplié par le temps lié aux défauts. On a alors : : <math>T = T_c + \text{Taux de défaut} \times T_m</math> On voit que plus le taux de succès est élevé, plus le temps de latence moyen sera bas, et inversement. Ce qui explique l'influence du taux de succès sur les performances du cache, influence assez importante sur les processeurs actuels. De nos jours, le temps que passe le processeur dans les défauts de cache devient de plus en plus un problème au fil du temps, et gérer correctement le cache est une nécessité, particulièrement sur les processeurs multi-cœurs. Il faut dire que la différence de vitesse entre processeur et mémoire est tellement importante que les défauts de cache sont très lents : alors qu'un succès de cache va prendre entre 1 et 5 cycles d'horloge, un cache miss fera plus dans les 400-1000 cycles d'horloge. Tout ce temps sera du temps de perdu que le processeur aura du mal à mitiger. Autant dire que réduire les défauts de cache est beaucoup plus efficace que d'optimiser les calculs effectués par le processeur (erreur courante chez de nombreux programmeurs, notamment débutants). ===L'impact de la taille du cache sur le taux de défaut et la latence=== Il y a un lien entre taille du cache, taux de défaut, débit binaire et latence moyenne. Globalement, plus un cache est gros, plus il est lent. Simple application de la notion de hiérarchie mémoire vue il y a quelques chapitres. Les raisons à cela sont nombreuses, mais nous ne pouvons pas les aborder ici, car il faudrait que nous sachions comment fonctionne un cache et ce qu'il y a à l'intérieur, ce qui sera vu dans la suite du chapitre. Toujours est-il que la latence moyenne d'un cache assez gros est assez importante. De même, le débit binaire d'un cache diminue avec sa taille, mais dans une moindre mesure. Les petits caches ont donc un gros débit binaire et une faible latence, alors que c'est l'inverse pour les gros caches. Une grande capacité de cache améliore le taux de succès, mais cela se fait au détriment de son temps de latence et de son débit, ce qui fait qu'il y a un compromis assez difficile à trouver entre taille du cache, latence et débit. Il peut arriver qu'augmenter la taille du cache augmente son temps d'accès au point d’entraîner une baisse de performance. Par exemple, les processeurs Nehalem d'Intel ont vus leurs performances dans certains jeux vidéos baisser de 2 à 3 %, malgré de nombreuses améliorations architecturales, parce que la latence du cache L1 avait augmentée de 2 cycles d'horloge. Pour avoir une petite idée du compromis à faire, regardons la relation entre taille du cache et taux de défaut. Il existe une relation approximative entre ces deux variables, appelée la '''loi de puissance des défauts de cache'''. Elle donne le nombre total de défaut de cache en fonction de la taille du cache et de deux autres paramètres. Voici cette loi : : <math>\text{Taux de défauts de cache} \approx K \times \text{Taille du cache}^{- \alpha }</math>, avec <math>K</math> et <math>\alpha</math> deux coefficients qui dépendent du programme exécuté. Le coefficient <math>\alpha</math> est généralement compris entre 0.3 et 0.7, guère plus, et varie suivant le programme exécuté. Précisons que cette loi ne marche que si le cache est assez petit par rapport aux données à utiliser. Pour un cache assez gros et des données très petites, la relation précédente est mise en défaut. Pour s'en rendre compte, il suffit d'étudier le cas extrême où toutes les données nécessaires tiennent dans le cache. Dans ce cas, il n'y a qu'un nombre fixe de défauts de cache : autant qu'il faut charger de données dans le cache. Le nombre de défauts de cache observé dans cette situation n'est autre que le coefficient <math>K</math> de la situation précédente, mais il n'y a aucune dépendance entre taux de défaut et taille du cache. L'origine de cette relation s'explique quand on regarde combien de fois chaque donnée est réutilisée lors de l’exécution d'un programme. La plupart des données finissent par être ré-accédées à un moment ou un autre et il se passe un certain temps entre deux accès à une même donnée. Sur la plupart des programmes, les observations montrent que beaucoup de réutilisations de données se font après un temps très court et qu'inversement, peu de ré-accès se font après un temps inter-accès long. Si on compte le nombre de réutilisation qui ont un temps inter-accès bien précis, on retrouve une loi de puissance identique à celle vue précédemment : : <math>\text{Nombre de réaccès avec un temps inter-accès égal à t} \approx K \times t^{- \beta}</math>, avec t le temps moyen entre deux réutilisations. Le coefficient <math>\beta</math> est ici compris entre 1.7 et 1.3. De manière générale, les coefficients <math>\alpha</math> et <math>\beta</math> sont reliés par la relation <math>\alpha = 1 - \beta</math>, ce qui montre qu'il y a un lien entre les deux relations. Précisons cependant que la loi de puissance précédente ne vaut pas pour tous les programmes informatiques, mais seulement pour la plupart d’entre eux. Il n'est pas rare de trouver quelques programmes pour lesquels les accès aux données sont relativement prédictibles et où une bonne optimisation du code fait que la loi de puissance précédente n'est pas valide. La loi de puissance des défauts de cache peut se démontrer à partir de la relation précédente, sous certaines hypothèses. Si un suppose que le cache est assez petit par rapport aux données, alors les deux relations sont équivalentes. L'idée qui se cache derrière la démonstration est que si le temps entre deux accès à une donnée est trop long, alors la donnée accédée aura plus de chance d'être rapatriée en RAM, ce qui cause un défaut de cache. La chance de rapatriement dépend de la taille du cache, un cache plus gros peut conserver plus de données et a donc un temps avant rapatriement plus long. ==Les lignes de cache et leurs tags== Du point de vue du processeur, les lectures et écritures se font mot mémoire par mot mémoire. Un processeur avec des entiers de 64 bits recoit des données de 64 bits de la part du cache, et y écrit des mots de 64 bits. Mais quand on regarde comment sont stockées les données à l'intérieur du cache, les choses sont différentes. ===Les lignes de cache=== Les données sont mémorisées dans le cache par blocs de plusieurs bytes, d'environ 64 à 256 octets chacun, qui portent le nom de '''lignes de cache'''. Les lignes de cache sont l'unité de stockage que l'on trouve à l'intérieur du cache, mais elles servent aussi d'unité de transaction avec la mémoire RAM. Sur les caches actuels, on transfère les données entre le cache et la RAM ligne de cache par ligne de cache, dans la limite de la taille du bus mémoire. Mais d'autres caches plus anciens permettaient de faire des transferts plus fins. C’est-à-dire qu'on pouvait mettre à jour quelques octets dans une ligne de cache sans avoir à la recopier intégralement depuis ou dans la mémoire RAM. En théorie, on pourrait imaginer des caches où les données sont stockées différemment, où l'unité serait le mot mémoire, par exemple. Par exemple, sur un processeur 64 bits, on aurait une ligne de cache de 64 bits. Cela aurait l'avantage de la simplicité : les transferts entre le processeur et la mémoire serait de même taille, l'intérieur du cache ressemblerait à son interface montrée au processeur. Mais cela aurait quelques défauts qui sont compensés par l'organisation en lignes de cache de grande taille. Le premier avantage des lignes de cache est lié à la localité spatiale, la tendance qu'on les programmes à accéder à des données proches les unes des autres. Des accès mémoires consécutifs ont tendance à se faire à des adresses proches, qui ont de bonnes chances d'être dans la même ligne de cache. Et des accès consécutifs à une même ligne de cache sont plus rapides que des accès à deux lignes distinctes. Une autre raison est tout simplement que cela simplifie considérablement la circuiterie du cache. Pour une capacité identique, il vaut mieux avoir peu de lignes de cache assez grosses, que beaucoup de petites lignes de cache. La raison est que les circuits du cache, comme le décodeur, l'encodeur et autres, ont moins de sorties et sont donc plus simples. ===L'alignement des lignes de cache=== Les lignes de cache sont des blocs de plusieurs dizaines à centaines de bytes, dont la taille est presque toujours une puissance de deux. De plus, les lignes de cache sont alignées en mémoire. Nous avions déjà abordé la notion d'alignement mémoire dans un chapitre précédent, mais le concept d'alignement des lignes de cache est quelque peu différent. Quand nous avions parlé d'alignement auparavant, il s'agissait de l'alignement des données manipulées par le processeur, qui faisait partie du jeu d'instruction du processeur. Ici, nous parlons d'un alignement totalement différent, invisible pour le programmeur, sans lien avec le jeu d’instruction. Voyons de quoi il retourne. Concrètement, cela veut dire que du point de vue du cache, la RAM est découpée en blocs qui font la même taille qu'une ligne de cache, aux positions prédéterminées, sans recouvrement entre les blocs. Par exemple, pour un cache dont les lignes de cache font 256 octets, le premier bloc est à l'adresse 0, le second est 256 octets plus loin, c'est à dire à l'adresse 256, le troisième à l'adresse 512, la quatrième à l'adresse 768, etc. Une ligne de cache de 256 octets contiendra une donnée provenant d'un bloc de RAM de 256 octets, dont l'adresse est systématiquement un multiple de 256. Il n'est pas possible qu'une ligne de cache contienne un bloc de 256 octets dont l'adresse du premier octet serait l'adresse 64, ou l'adresse 32, par exemple. En clair, les adresses de ces blocs sont des multiples de la taille de la ligne de cache, de la taille des blocs. Cela rappelle les contraintes d'alignement vues dans le chapitre "Le modèle mémoire : alignement et boutisme", mais appliquées aux lignes de cache. L'alignement des lignes de cache a des conséquences pratiques pour la conception des caches. Notons qu'il est en théorie possible d'avoir des caches dont les lignes de cache ne sont pas alignées, mais cela poserait des problèmes majeurs. Il serait en effet possible qu'une donnée soit présente dans deux lignes de cache à la fois. Par exemple, prenons le cas où une ligne de cache de 256 commence à l'adresse 64 et une autre ligne de cache commence à l'adresse 0. L'adresse 128 serait dans les deux lignes de cache ! Et cela poserait des problèmes lors des lectures, mais encore plus lors des écritures. C'est pour éviter ce genre de problèmes que les lignes de cache sont alignées avec la mémoire RAM dans tous les caches existants. L'alignement des lignes de cache est une chose que les programmeurs doivent parfois prendre en compte quand ils écrivent du code ultra-optimisé, destiné à des programmes demandant des performances extrêmes. Il arrive que les contraintes d'alignement posent des problèmes. Nous avions vu dans le chapitre sur le boutisme et l'alignement qu'il valait mieux gérer l'alignement des variables des structures de données, pour éviter les accès non-alignés avec le bus mémoire. La même chose est possible, mais pour l'alignement avec des lignes de cache. Typiquement, l'idéal est que, pour une structure de donnée, on puisse en mettre un nombre entier dans une ligne de cache. Ou alors, si la structure est vraiment grande, que celle-ci occupe un nombre entier de lignes de cache. Si ce n'est pas le cas, il y a un risque d'accès non-alignés, c'est à dire qu'une structure se retrouve à cheval sur deux lignes de cache, avec les défauts que cela implique. ===Le tag d'une ligne de cache=== Plus haut, nous avions dit que le cache mémorise, pour chaque ligne de cache, l'adresse RAM associée. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Mais du fait de l'organisation du cache en lignes de cache de grande taille, qui sont de plus alignées en mémoire, il faut nuancer cette affirmation. Le cache ne mémorise pas la totalité de l'adresse, ce qui serait inutile. L'alignement des lignes de cache en RAM fait que les bits de poids faible de l'adresse ne sont pas à prendre en compte pour l'association adresse-ligne de cache. Dans ces conditions, on mémorise seulement la partie utile de l'adresse mémoire correspondante, qui forme ce qu'on appelle le '''tag'''. Le reste de l'adresse indique quelle est la position de la donnée dans la ligne de cache. Par exemple, prenons le cas où le processeur gère des nombres entiers de 64 bits (8 octets) et des lignes de cache de 128 octets : chaque ligne de cache contient donc 16 entiers. Si le processeur veut lire ou écrire un entier bien précis, il doit préciser sa place dans la ligne de cache. Et ce sont les bits de l'adresse mémoire non-inclus dans le cache qui permettent de faire ça. En clair, une adresse mémoire à lire/écrire est interprété par le cache comme la concaténation d'un tag et de la position de la donnée dans la ligne de cache correspondante. [[File:Adressage d'un cache totalement associatif.png|centre|vignette|upright=2|Adressage d'un cache totalement associatif]] Le cache est donc une grande table de correspondance entre tags et lignes de cache. Lors d'un accès mémoire, le cache extrait le tag de l'adresse à lire ou écrire, et le compare avec les tags de chaque ligne de cache. Si une ligne contient ce tag, alors c'est que cette ligne correspond à l'adresse, et c'est un défaut de cache sinon. Lors d'un succès de cache, la ligne de cache est lue depuis le cache et envoyée à un multiplexeur qui sélectionne la donnée à lire dans la ligne de cache. Le fonctionnement est similaire pour une écriture : la donnée à écrire passe dans un démultiplexeur, qui envoie la donnée au bon endroit dans la ligne de cache sélectionnée. [[File:Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.png|centre|vignette|upright=2|Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.]] ===Le contenu d'une ligne de cache=== Dans ce qui va suivre, nous allons considérer que chaque ligne de cache mémorise son tag, les données de la ligne de cache proprement dit, et quelques bits de contrôle annexes qui varient suivant le cache considéré. [[File:Tag d'une ligne de cache.png|centre|vignette|upright=2|Tag d'une ligne de cache.]] Les caches modernes incluent de nombreux bits de contrôle, mais deux d'entre eux sont communs à presque tous les caches modernes : le bit ''Dirty'' et le bit ''Valid''. Le '''bit ''Valid''''' indique si la ligne de cache contient des données valides ou non. Si le bit ''Valid'' est à 0, la ligne de cache est en état valide, à savoir qu'elle contient des données et n'est pas vide. Par contre, si ce bit est à 1, la ligne de cache est invalide et son contenu ne peut pas être lu ou écrit. L'utilité de ce bit est qu'il permet d'effacer une ligne de cache très rapidement : il suffit de mettre ce bit à 0. Il existe des situations où le cache doit être effacé, on dit alors qu'il est invalidé. Une section de ce chapitre sera dédié à l'invalidation du cache. Le '''bit ''Dirty''''' indique qu'une ligne de cache a été modifiée. Par modifiée, on veut dire que le processeur a écrit dedans, qu'il a modifié la ligne de cache. Mais attention : si la donnée a été modifiée dans le cache, la modification n'est pas forcément propagée en mémoire RAM. Le bit ''dirty'' indique si c'est le cas, si l'écriture a été propagée en mémoire RAM. Il précise que la ligne de cache contient des données modifiées, alors que la RAM a des données initiales non-modifiées. Une ligne de cache avec un bit ''dirty'' à 1 est dite ''dirty'', par métonymie. Nous verrons cela en détail dans la section sur les caches ''write-back'' et ''write-through''. Les caches modernes ajoutent des '''bits de détection/correction d'erreur''' dans les bits de contrôle. Pour rappel, les codes de détection/correction d'erreur permettent de se prémunir contre des erreurs matérielles, qui corrompent les données stockées dans une mémoire, ici une mémoire cache. Ils ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Nous reviendrons dessus dans une section ultérieur de ce chapitre. Sur certains caches assez anciens, on pouvait transférer les lignes de caches morceaux par morceaux. Ces caches avaient des lignes de cache divisées en sous-secteurs, ces sous-secteurs étant des morceaux de ligne de cache qu'on pouvait charger indépendamment les uns des autres (mais qui sont consécutifs en RAM). Chaque secteur avait ses propres bits de contrôle, mais le tag était commun à tous les secteurs. [[File:Cache à secteurs.png|centre|vignette|upright=2.5|Cache à secteurs.]] : Dans ce qui va suivre, le terme "ligne de cache" désignera soit un bloc de données copiées depuis la RAM d'une taille de 64/128/256/... octets, soit la concaténation de ces données avec le tag et des bits de contrôle. Les deux définitions ne sont pas équivalentes, mais l'usage a entériné cet abus de langage. Et il faut avouer que cela rend les explications du chapitre plus simples. ==Les instructions de contrôle du cache== Plus haut, nous avions dit que le cache est totalement transparent du point de vue du programmeur. Le cache contient des copies de données en RAM, le programmeur n'a rien à faire pour utiliser le cache correctement. Mais la réalité est que pour des raisons diverses, des processeurs incorporent des '''instructions de contrôle du cache'''. Il s'agit d’instructions qui agissent sur le contenu du cache. Elles existent pour des raisons diverses qu'on détaillera plus bas, mais il s'agit globalement d'une question de performances ou de nécessité pour le système d'exploitation. ===Les instructions de préchargement=== La première instruction de contrôle du cache est une '''instruction de préchargement''', qui demande à charger un bloc de données dans le cache. Elle prend en opérande une adresse mémoire, et le contenu de cette adresse est chargé dans une ligne de cache. Bien sûr, des contraintes d'alignement sont à prendre en compte : on charge un bloc de la même taille qu'une ligne de cache, aligné en mémoire sur la taille du bloc, qui contient l'adresse. L'instruction de préchargement n'est utile que si l'instruction est exécutée bien avant que la donnée ne soit utilisée/lue/écrite. Cela permet de charger une donnée dans le cache à l'avance, d'où le nom de préchargement donné à cette technique. Mais les processeurs modernes gérent des techniques de préchargement automatique, qui ne requièrent pas d'instructions de préchargement. Le préchargement automatique et les instructions de préchargement sont deux solutions complémentaires, mais qui peuvent se marcher sur les pieds. Nous en reparlerons dans le prochain chapitre, qui sera dédié au préchargement automatique. Il faut noter que les instructions de préchargement peuvent être ignorées par le processeur. Sous certaines conditions, le processeur peut décider que l'instruction de préchargement ne sera pas exécutée. Par exemple, il ne va pas précharger une donnée déjà présente dans le cache. Ou encore, si le bus mémoire est occupé, il ne va pas exécuter le préchargement, par manque de ressources matérielles. ===Les instructions d'invalidation et de ''flush''=== Les instructions ''flush'' regroupent deux types d'instructions qui sont souvent utilisées en même temps. Il s'agit des instructions d'invalidation et de nettoyage (''clean''). Les deux termes proviennent de la terminologie ARM, il n'y a pas de terminologie standardisé pour les noms de ces instructions. Dans les grandes lignes, elles permettent de vider le cache, à savoir de rapatrier son contenu en RAM et de réinitialiser le cache à zéro. Elles sont utilisées par le système d'exploitation lors des commutations de contexte, à savoir quand on passe d'un programme à un autre. Elles sont aussi utilisées lors des appels systèmes et routines d'interruption/exception. L'idée est de vider le cache avant d'exécuter un nouveau programme ou une nouvelle routine. Le nouveau programme aura accès à un cache tout propre, les données de l'ancien programme auront été retirée du cache. Les '''instructions ''clean''''' recopient le contenu de la ligne de cache en RAM. Elles forcent la recopie immédiatement de la ligne de cache en mémoire RAM. Pour faire leur travail, elle vérifient si la ligne de cache a été modifiée, avant de la recopier en RAM. Et pour cela, ils vérifient le bit de contrôle ''dirty'', qui est mis à 1 après une première écriture. Si ce bit est à 0, alors pas besoin de recopier la ligne de cache : elle n'a pas été modifiée, la RAM a déjà la bonne copie. Mais s'il est à 1, le cache et la RAM n'ont pas le même contenu, la recopie s'exécute. Les '''instructions d'invalidation''' permettent d'invalider une ligne de cache, à savoir d'effacer son contenu. Nous verrons à quoi servent ces instructions dans la section sur les changement de processus. Invalider une ligne de cache est une opération optimisée : le cache n'est en réalité pas réellement effacé. À la place, le bit ''Valid'' de chaque ligne de cache est juste mis à 0. Il faut noter que l'invalidation efface les lignes de cache sans se préoccuper de leur contenu. Elle se moque qu'une ligne de cache contienne une donnée modifiée, ''dirty'' ou quoique ce soit : la ligne de cache est effacée, point. Il est possible d'invalider une ligne de cache en fournissant une adresse mémoire, mais il est aussi possible d'invalider le cache tout entier. Le choix entre les deux dépend du mode d'adressage de l'instruction d'invalidation. Parfois, il existe une instruction séparée pour invalider tout le cache, et une autre pour invalider une ligne de cache bien précise. Des instructions séparées sont parfois disponibles pour invalider les caches de données et d'instructions, parfois aussi la TLB (un cache qu'on verra dans quelques chapitres). Il est possible de n'invalider que le cache L1, voire le cache L2. Il faut noter que l'invalidation efface tout le cache, mais ne se préoccupe pas de vérifier si les données ont été modifiées dans le cache. Pour certains caches, comme le cache d'instruction, ce n'est pas un problème, vu qu'il est en "lecture seule". Mais pour les caches de données, les données modifiées sont perdues en cas d'invalidation. Heureusement, il existe des instructions d'invalidation qui fusionnent une instruction ''clean'' et une instruction d'invalidation. Il s'agit d''''instructions d'invalidation spéciales'''. ===Les instructions d'optimisation : instructions non-temporelles et écritures optimisées=== Les '''instructions mémoire non-temporelles''' contournent complètement le cache. Par exemple, une lecture peut lire une donnée, mais celle-ci ne sera pas chargée dans le cache, elle passe directement de la RAM vers les registres. Une section entière de ce chapitre sera dédiée au contournement du cache, à savoir aux situations où les accès mémoire doivent passer directement du processeur à la RAM sans passer par le cache. D'autres instructions assez rares incorporent des indications pour le cache. Par exemple, l'instruction ''load last'' des processeurs POWER PC implique que la donnée ne sera utilisée qu'une seule fois. Elle est donc chargée dans le cache, mais la ligne de cache est configurée de manière à être remplacée très rapidement, typiquement avec une valeur de LRU/LFU adéquate. La donnée est bien chargée dans le cache, au cas où elle doive être relue suite à une mauvaise prédiction de branchement ou autre, chose qu'une lecture non-temporelle (qui contourne le cache) ne fait pas. Des indications de ce type sont appelées des '''''cache hint'''''. L''''instruction ''flush''''' permet de préciser qu'une ligne de cache contient une donnée inutile, qui ne sera pas réutilisée par le programme. Pas besoin de la conserver dans le cache, elle peut laisser sa place à des données plus utiles. Or, sans indication, les algorithmes de remplacement d'une ligne de cache risquent de conserver cette donnée trop longtemps, ce qui entraine une certaine pollution du cache par des données inutiles. Une autre instruction est elle beaucoup plus importante : celle de '''pré-allocation sur écriture'''. Elle sert dans le cas où une ligne de cache est complétement écrite. Par exemple, imaginons qu'on veuille écrire dans une portion de mémoire. Si celle-ci n'est pas dans le cache, le processeur va charger une ligne de cache complète depuis la RAM, écrire dans la ligne de cache, puis recopier la ligne de cache modifiée en mémoire RAM. Une écriture en RAM demande donc de faire une lecture et une écriture. Mais les instructions de pré-allocation sur écriture permettent de prévenir qu'une ligne de cache sera intégralement écrite, et qu'il n'y a donc pas besoin de lire celle-ci depuis la RAM. Notons que l'instruction d'écriture qui suit n'est pas une écriture non-temporelle, vu que les données sont écrites dans la ligne de cache, qui est ensuite envoyée en mémoire RAM dès que nécessaire. De plus, les données écrites peuvent ensuite être relue depuis le cache si nécessaire. Enfin, certains processeurs MIPS incorporent une instruction pour modifier le tag d'une ligne de cache. Elles servent à optimiser les copies mémoire, à savoir quand on copie un bloc de données d'un endroit à un autre. L'idée est de charger le bloc de données dans le cache avec une instruction LOAD/PREFETCH, de modifier le tag pour qu'il pointe vers l'adresse à écrire, et de laisser faire le cache pour que l'écriture se fasse en RAM. Mais les contraintes pour utiliser cette instruction sont assez drastiques : les données doivent être alignées sur la taille d'une ligne de cache, le bloc de départ et d'arrivée (l'original versus la copie) ne doivent pas se recouvrir, etc. ==L'associativité des caches et leur adressage implicite== Lorsqu'on souhaite accéder au cache, il faut trouver quelle est la ligne de cache dont le tag correspond à l'adresse demandée. On peut classifier les caches selon leur stratégie de recherche de la ligne correspondante en trois types de caches : totalement associatifs, directement adressés (''direct mapped'') et associatifs par voie. ===Les caches totalement associatifs=== Avec les caches totalement associatifs, toute donnée chargée depuis la mémoire peut être placée dans n'importe quelle ligne de cache, sans aucune restriction. Ces caches ont un taux de succès très élevé, quand on les compare aux autres caches. [[File:Cache totalement associatif.png|centre|vignette|upright=2|Cache totalement associatif.]] Concevoir un cache totalement associatif peut se faire de deux grandes manières différentes. La première consiste tout simplement à combiner une mémoire associative avec une mémoire RAM, en ajoutant éventuellement quelques circuits annexes. La mémoire associative mémorise les tags, alors que la mémoire RAM mémorise les données de la ligne de cache, éventuellement avec quelques bits de contrôle. La ligne de cache est stockée à une adresse A dans la mémoire RAM et son tag est stocké à la même adresse, mais dans la mémoire CAM. Ce faisant, quand on envoie le tag à la mémoire CAM, elle renvoie l'adresse de la ligne de cache dans la mémoire RAM. Cette adresse est alors envoyée directement sur le bus d'adresse de la RAM, et la lecture est effectuée automatiquement. Il faut ajouter quelques circuits annexes pour garantir que les écritures se passent correctement dans les deux mémoires, mais rien de bien terrible. [[File:Cache fabriqué avec une mémoire associative et une RAM.png|centre|vignette|upright=3|Cache fabriqué avec une mémoire associative et une RAM]] Il est cependant possible d'optimiser un tel cache, en fusionnant la mémoire CAM et la mémoire RAM, afin d'éliminer des circuits redondants. Pour comprendre pourquoi, rappelons que les mémoires CAM sont composées d'un plan mémoire, d'un paquet de comparateurs et d'un encodeur. Quant à la mémoire RAM, elle est composée d'un décodeur connecté au plan mémoire. En mettant une CAM suivie d'une RAM, on a un encodeur dont l'entrée est envoyée à un décodeur. [[File:Cache totalement associatif naif.png|centre|vignette|upright=3|Cache totalement associatif naif]] Or, le décodeur réalise l'opération inverse de l'encodeur, ce qui fait que mettre les deux composants à la suite ne sert à rien. On peut donc retirer l'encodeur et le décodeur, et envoyer directement les résultats des comparateurs sur les entrées de commande du plan mémoire de la RAM. [[File:Cache totalement associatif optimisé.png|centre|vignette|upright=2|Cache totalement associatif optimisé]] Avec cette méthode, les circuits du cache ressemblent à ce qui illustré ci-dessous. Le tag est envoyé à chaque ligne de cache. Le tag envoyé est alors comparé avec le Tag contenu dans chaque ligne de cache, comme c'est le cas sur les mémoires associatives. Si une ligne de cache matche avec le tag envoyé en entrée, la ligne pour laquelle il y a eu une égalité est alors connectée sur les lignes de bit (''bitlines''). Cela est réalisé par un circuit commandé par le comparateur de la ligne de cache. Il ne reste plus qu'à sélectionner la portion de la ligne de cache qui nous intéresse, grâce à un paquet de multiplexeurs. Cela permet d'effectuer une lecture ou écriture, mais il faut aussi préciser si il y a eu un défaut de cache ou un succès. Un succès de cache a lieu quand au moins des comparaisons est positive, alors que c'est un défaut de cache sinon. En clair, détecter un succès de cache demande juste de connecter une porte OU à plusieurs entrées à tous les comparateurs. [[File:Organisation générale d'un cache totalement associatif.png|centre|vignette|upright=2|Organisation générale d'un cache totalement associatif.]] ===Les caches directement adressés=== Les caches directement adressés peuvent être vus comme un cache totalement associatif auquel on aurait ajouté des restrictions assez drastiques. Plus haut, on a vu qu'un cache totalement adressé est équivalent à la combinaison d'une CAM avec une RAM. La mémoire CAM prend en entrée un Tag et traduit celui-ci en une adresse qui commande la mémoire RAM interne au cache. Dans ce qui suit, l'adresse interne au cache sera appelé l''''indice''' pour éviter toute confusion. [[File:Cache hash table - 2.png|centre|vignette|upright=2|Fonctionnement interne du cache, expliquée sous forme abstraite, en utilisant la notion d'indice interne au cache.]] Les caches directement adressés cherchent à remplacer la mémoire CAM par un circuit combinatoire. Ce circuit traduit le Tag en indice, mais est beaucoup plus simple qu'une mémoire CAM. Mais qui dit circuit plus simple dit circuit plus limité. Un circuit combinatoire n'est pas aussi versatile que ce qui est permis avec une mémoire CAM. En conséquence, une restriction majeure apparait : toute adresse mémoire est associée dans une ligne de cache prédéfinie, toujours la même. L'association entre ligne de cache et adresse mémoire est faite par le circuit combinatoire, et ne peut pas changer. Les concepteurs de caches s'arrangent pour que des adresses consécutives en mémoire RAM occupent des lignes de cache consécutives, par souci de simplicité. Tout se passe comme suit la mémoire RAM était découpés en blocs de la même taille que le cache. La première adresse du bloc est associée à la première ligne de cache (celle d'indice 0), la seconde adresse est associée à la seconde adresse du_ bloc, et ainsi de suite. Le tout est illustré ci-dessous. [[File:Cache adressé directement.png|centre|vignette|upright=2|Cache adressé directement.]] Avec cette contrainte, le circuit de traduction de l'adresse en adresse mémoire pour la RAM interne au cache est drastiquement simplifié, et disparait même. Une partie de l'adresse mémoire sert à indiquer la position de la donnée dans le cache, le reste de l'adresse sert encode le tag et la position de la donnée dans le ligne de cache. [[File:Cache line.png|centre|vignette|upright=2|Adresse d'une ligne de cache sur un cache adressé directement.]] Un cache directement adressé est conçu avec une RAM, un comparateur, et un paquet de multiplexeurs. En général, la mémoire RAM stocke les lignes de caches complète. Il arrive que l'on utilise deux mémoires RAM : une pour les tags et une pour les données, mais cette technique augmente le nombre de circuits et de portes logiques nécessaires, ce qui réduit la capacité du cache. L'index à lire/écrire est envoyé sur l'entrée d'adresse de la RAM, la RAM réagit en mettant la ligne de cache sur sa sortie de donnée. Sur cette sortie, un comparateur compare le tag de la ligne de cache lue avec le tag de l'adresse à lire ou écrire. On saura alors si on doit faire face à un défaut de cache. Ensuite, un multiplexeur récupère la donnée à lire/écrire. [[File:Direct mapped cache - french.png|centre|vignette|upright=2|Cache directement adressé.]] L'accès à un cache directement adressé a l'avantage d'être très rapide vu qu'il suffit de vérifier une seule ligne de cache : celle prédéfinie. Mais ces caches ne sont cependant pas sans défauts. Vu que le cache est plus petit que la mémoire, certaines adresses mémoires se partagent la même ligne de cache. Si le processeur a besoin d’accéder fréquemment à ces adresses, chaque accès à une adresse supprimera l'autre du cache : tout accès à l'ancienne adresse se soldera par un défaut de cache. Ce genre de défauts de cache causés par le fait que deux adresses mémoires ne peuvent utiliser la même ligne de cache s'appelle un '''défaut par conflit''' (''conflict miss''). Les défauts par conflit n'existent pas sur les caches totalement associatifs. En conséquence, le taux de succès des caches directement adressés est assez faible comparé aux autres caches. [[File:Cache Block Basic Conflict.svg|centre|vignette|upright=1.5|Exemple de ''Conflict Miss''.]] ===Les caches associatifs par voie=== Les caches associatifs par voie sont un compromis entre les caches directement adressés et les caches totalement associatifs. Pour simplifier, ces caches sont composés de plusieurs caches directement adressés accessibles en parallèle, chaque cache/RAM étant appelé une '''voie'''. Avec ces caches, toute adresse mémoire en RAM est associée à une ligne de cache dans chaque voie. [[File:Cache associatif par voie.png|centre|vignette|upright=2|Cache associatif par voie.]] Le schéma ci-dessous compare un cache directement adressé et un cache associatif à deux voies. On voit que chaque adresse est associée à une ligne de cache bien précise avec un cache directement dressé, et à deux lignes de cache avec un cache associatif à deux voies. L'adresse sera associée à 4 lignes de cache sur un cache associatif à 4 voies, à 8 lignes pour un cache à 8 voies, etc. L'ensemble des lignes de cache associées à une adresse est appelé un '''ensemble'''. [[File:Cache Fill.svg|centre|vignette|upright=2|Comparaison entre un cache directement adressé et un cache associatif à deux voies.]] Sur ces caches, toute adresse est découpée en trois parties : un tag, un index, et un décalage, comme sur les caches directement adressés. Comme vous pouvez le voir, l'organisation est identique à celle d'un cache totalement associatif, à part que chaque ensemble tag-ligne de cache est remplacé par une mémoire RAM qui en contient plusieurs. [[File:Implémentation d'un cache associatif par voie.png|centre|vignette|upright=2|Implémentation d'un cache associatif par voie.]] Le risque de conflits d'accès au cache est donc réduit sur un cache associatif à plusieurs voies, et il est d'autant plus réduit que le cache a de voies. Par contre, leur conception interne fait qu'ils ont un temps d'accès légèrement élevé que les caches directement adressés. Les caches associatifs par voie ont donc un taux de succès et un temps d'accès intermédiaire, situé entre les caches directement adressés et totalement associatifs. Ils sont une sorte de compromis entre réduction des défaut par conflits d'accès au cache et temps d'accès, et complexité des circuits. ==Les optimisations des caches associatifs par voie== Les caches partiellement associatifs regroupent les caches associatifs par voie et directement adressés, ainsi que leurs variantes. En clair : tous les caches qui ne sont pas totalement associatifs. Ils peuvent être optimisés de nombreuses manières, que ce soit pour gagner en performance ou pour économiser de l’énergie. Dans cette section, nous allons voir quelles sont ces optimisations. ===Les caches pseudo-associatifs=== Les caches adressés par voie contiennent une mémoire SRAM par voie. En théorie, les voies sont accédées en parallèles, en même temps, afin de voir si l'on a un succès de cache ou un défaut. Les '''caches pseudo-associatifs''' sont identiques aux caches associatifs par voie, si ce n'est qu'ils vérifient chaque voie une par une. Ils ont été utilisés sur des processeurs commerciaux, un exemple étant l'IBM 370. Là encore, on perd en performance pour gagner en consommation d'énergie. Le temps d'accès dans le meilleur des cas est plus faible pour les caches pseudo-associatifs, mais le pire des cas teste tous les caches avant de tomber sur le bon. Les performances sont donc réduites. Mais la consommation énergétique est meilleure, vu qu'on ne vérifie pas forcément toutes les voies en parallèle. On teste la première voie, éventuellement la seconde, peut-être la troisième, etc. Mais dans le cas général, on ne teste qu'une partie des voies, pas toutes, ce qui donne un gain en termes d'énergie. L'implémentation de caches de ce genre demande que l'on parcoure les voies une par une, en commençant de la première jusqu'à la dernière. Pour cela, un simple compteur suffit. Suivant la valeur du compteur, la voie associée est activée puis accédée. Toute la complexité revient à ajouter un circuit qui prend la valeur du compteur, et active la voie associée, lance un accès mémoire dessus. Vu que les voies sont chacune des caches ''direct mapped'', il suffit pour cela de geler les entrées d'adresse, soit en les déconnectant, soit en utilisant du ''clock gating'' ou de l'évaluation gardée. Les détails d'implémentation, non-cités ici, varient selon le cache. ===La prédiction de voie=== Pour réduire le temps d'accès des caches pseudo-associatifs, certains chercheurs ont inventé la '''prédiction de voie''', qui consiste à faire des paris sur la prochaine voie accédée. L'idée est d'accéder à la voie qui contient la donnée voulue du premier coup, en lisant celle-ci en priorité. Dans son implémentation la plus simple, le cache reste un cache pseudo-associatif. Lors d'un accès au cache, les voies sont toutes parcoures une par une. Par contre, les voies ne sont donc pas parcourues de la première vers la dernière, mais dans un ordre différent. Cette technique permet de mettre en veille les voies sur lesquels le processeur n'a pas parié, ce qui permet de diminuer la consommation énergétique du processeur. C'est plus efficace que d'aller lire plusieurs données dans des voies différentes et de n'en garder qu'une. L'implémentation est assez simple : il suffit d'ajouter un circuit de prédiction de voie,relié au compteur de voie. Une amélioration de la technique fait fonctionner le cache comme un intermédiaire entre cache pseudo-associatif et associatif par voies. L'idée est de chercher la voie prédite en premier, puis de chercher dans toutes les voies en parallèle en cas de défaut de cache. Au lieu d'attendre que les comparaisons de tags donnent leur résultat, le processeur sélectionne automatiquement une voie et configure les multiplexeurs à l'avance. Si le processeur ne se trompe pas, le processeur accède à la donnée plus tôt que prévu. S'il se trompe, le processeur annule la lecture effectuée en avance et recommence en faisant un accès en parallèle aux autres voies. Le compromis entre performance et consommation d'énergie est alors différent. On économise de l'énergie par rapport à un cache associatif par voie, au prix d'une petite perte de performance (doublement des temps d'accès). Mais par rapport à un cache pseudo-associatif, l'économie d'énergie est bien moindre, au prix d'un gain en performance assez manifeste. Prédire quelle voie sera la bonne est assez simple. En vertu du principe de localité, les accès futurs ont des chances de tomber dans les voies les plus fréquemment utilisées ou dans celle plus récemment utilisée. Il suffit de retenir la voie la plus récemment accédée dans un registre, qui sera utilisée comme prédiction. Pour vérifier que la prédiction est correcte, il suffit de comparer le registre et le résultat obtenu après vérification des tags. Cependant, on peut complexifier l'implémentation pour prendre en compte l'adresse à lire/écrire, l'instruction à l'origine de l'accès mémoire ou tout autre paramètre utile. Par exemple, des instructions différentes ont tendance à aller chercher leurs données dans des ensembles différents et la voie à choisir n'est pas la même. Pour cela, il suffit d'utiliser un cache pour stocker la correspondance instruction - voie. Pour plus de simplicité, la mémoire cache des prédictions est parfois remplacée par une RAM, qui est adressée : * soit par le program counter de l'instruction à l'origine de l'accès (en réalité, seulement quelques bits de poids faible de l'adresse) ; * soit par l'adresse à accéder (là encore, quelques bits de poids faible) ; * soit (pour les modes d'adressage qui utilisent un registre de base et un décalage) par un XOR entre les bits de poids faible de l'adresse de base et le décalage ; * soit par autre chose. ===La mise en veille sélective des voies=== Les caches associatifs ont tendance à utiliser beaucoup d'énergie, même quand on n'y accède pas. Aussi, certains processeurs détectent quand le cache est peu utilisé et en profitent pour mettre en veille les voies inutilisées. Vous vous demandez certainement ce qui se passe quand une donnée à lire/écrire est dans une voie désactivée. La réponse est que le cache détecte cette situation, car elle déclenche un succès de cache. Les ''tags'' ne sont en effet pas désactivés, seules les données sont mises en veille. L'implémentation est plus simple sur les caches qui séparent les tags et les données dans deux RAM différentes. Cette optimisation marche surtout sur les gros caches, qui ont des chances d'avoir une portion significative d’inutilisée (pas assez de données pour les remplir), donc généralement les caches L3/L4. Par exemple, les processeurs d'Intel de microarchitecture Ivy Bridge disposent d'un cache de 8 mébioctets à 16 voies, qu'ils peuvent faire passer à 512 kibioctets si le besoin s'en fait sentir. Quand ces processeurs détectent une faible activité, ils mettent en veille 14 voies et n'en gardent que 2 d'actives. Évidemment, les 14 voies sont vidées avant d'être mises en veille, afin qu'une aucune donnée ne soit perdue. ===Les caches ''skew-associative''=== Vous aurez remarqué que dans une voie, les lignes sont accédées en adressage direct : les défauts par conflit sont possibles sur un cache associatif par voie. Pour éviter cela, certains chercheurs ont créé des '''caches ''skew associative''''' (ou associatifs à biais). Pour faire simple, les index des lignes de cache subissent un petit traitement avant d'être utilisés. Le traitement en question est différent suivant la voie de destination, histoire que deux adresses mémoires avec des index identiques donnent des index différents après traitement. Le traitement en question est souvent une permutation des bits de l'index, qui est différente suivant la voie prise, ou un simple XOR avec un nombre qui dépend de la voie. [[File:Implémentation d'un cache skew associative.jpg|centre|vignette|upright=2|Implémentation d'un cache skew associative.]] ==Les caches splittés (''phased caches'')== Dans cette section, nous allons voir les '''caches splittés''' (''phased caches''), qui sont une variante des caches ''direct-mapped'', dans lequel le cache est accédé en deux étapes consécutives. Il ne s'agit pas des caches pipelinés, que nous verrons dans le chapitre sur les processeurs pipélinés, mais laissons cela à plus tard. Il est possible d'appliquer la même méthode sur un cache associatif par voie, mais il y a des méthodes plus simples, qui permettent là aussi d’accéder au cache en plusieurs étapes consécutives. L'idée est de scinder le cache en deux : une mémoire pour les tags, une autre pour les données de la ligne de cache. Les bits de contrôle peuvent être mis dans l'une ou l'autre SRAM, mais ils sont souvent mis dans la RAM pour les tags. En faisant cela, quelques optimisations deviennent possibles, afin de réduire la consommation énergétique en contrepartie d'une perte de performance. La technique s'implémente différemment pour les caches totalement associatifs et partiellement associatifs. Les caches totalement associatifs splittés sont ceux formés en combinant un cache associatif avec une CAM et une RAM combinée. On envoie l'adresse à lire/écrire à la mémoire associative, elle répond en envoyant une adresse à la mémoire RAM. L'accès se fait donc en deux temps, avec l'adresse dans la RAM comme intermédiaire. Il est possible de séparer physiquement les deux étapes en insérant un registre entre la CAM et la RAM, ce qui permet aussi de pipeliner l'accès. Mais c'est rarement fait en pratique, car le cout en circuit d'une mémoire CAM est trop important. L'équivalent pour un cache totalement associatif optimisé, sans CAM et RAM séparée, est trop gourmande en interconnexions pour être implémentée. Les caches totalement associatifs splittés sont donc très rares, l'auteur ne connait aucun exemple de processeur avec un tel cache. Il existe une technique équivalente pour les caches ''direct-mapped'', mais elle demande une certaine modification du cache. Dans les caches ''direct-mapped'' non-splittés, on trouve une mémoire SRAM dont chaque mot mémoire contient une ligne de cache entière, tag inclus. Dans leurs versions splittés, la SRAM est séparée en deux : une pour les tags, une autre pour les données. Précisons qu'il s'agit bien de deux mémoires SRAM adressables. L'adresse à laquelle accéder est envoyée à la SRAM des tags, puis ensuite à la SRAM des données si besoin. L'idée est d’accéder aux tags pour déterminer s'il y a un succès de cache ou un défaut, et ensuite d'accéder aux données. On n’accède pas aux données en parallèle des tags. Faire cela est évidemment plus lent. En cas de défaut de cache, le temps d'accès est similaire : le tag ne correspond pas, on n'accède pas à la SRAM pour les données. Par contre, vu qu'on n'a pas activé la SRAM pour les données, on économise un peu d'énergie, ce qui réduit la consommation d'énergie. En cas de succès de cache, on accède à la SRAM pour les tags, puis à celle pour les données. Pas d'économie d'énergie à l'horizon, sans compter que le temps d'accès augmente : on accède au cache en deux étapes au lieu de faire les deux accès en parallèle. [[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]] Précisons cependant que ce design peut avoir deux avantages en termes de performance. Premièrement, le temps d'accès au cache est légèrement amélioré en cas de défaut de cache. En effet, la SRAM des tags est assez petite, idem pour celle des données. Leur temps d'accès est donc plus faible que pour une grosse SRAM contenant données et tags. Le gain en temps d'accès est donc un avantage, qui ne se manifeste surtout en cas de défaut de cache. Un autre avantage est que l'accès au cache se pipeline plus facilement, ce qui fait qu'on peut effectuer plusieurs accès simultanés au cache. Mais nous verrons cela dans quelques chapitres. ===L'exemple des processeurs 386 et du contrôleur de cache 82385=== Il est important de noter que la séparation entre tags et RAM peut être telle que les deux ne sont pas sur la même puce de silicium ! Voire que les deux sont séparés du processeur ! C'était le cas quand les mémoires caches ont été introduites sur les processeurs grand public, notamment sur les premiers processeurs Intel. La miniaturisation n'avait pas avancé au point où placer un cache dans le processeur était possible. Sur le processeur 386 d'Intel, le cache était un cache splitté, séparé du processeur. Concrètement, le processeur i386 était couplé à un contrôleur de cache Intel 82385 et une mémoire SRAM. Le 82385 contenait les ''tags'' et les bits de contrôle, la SRAM contenait les données, les lignes de cache. Un point important est que les lignes de cache faisaient seulement 32 bits/4 octets, pas plus ! On était loin des lignes de cache actuelles, faisant 64 octets/512 bits. Mais c'était beaucoup plus pratique, vu que le bus système faisait 32 bits de large, idem pour l'interface avec le processeur. Pour intégrer un cache facultatifs (certains ordinateurs 'en passaient). Le schéma ci-dessous montre comment le cache s'intégrait avec le bus système. Pour le bus de commande, le cache servait d'intermédiaire : il recevait les commandes et et les filtrait suivant les succès/défauts de cache. En cas de succès de cache, les commandes de lecture n'étaient pas envoyées à la mémoire RAM. Les adresses étaient transmises à la fois au cache et au bus système (avec un registre entre le bus système et le processeur). Le bus de donnée était lui connecté à la mémoire SRAM et au processeur, avec des MUX/DEMUX pour faire le choix de la source des lectures. [[File:Controleur de cache 82385 pour l'Intel 386.png|centre|vignette|upright=2.5|Contrôleur de cache 82385 pour l'Intel 386]] Le 82385 surveillait ce qui se passait sur le bus et répondait à la place de la RAM pour certaines lectures. C'était un intermédiaire assez passif, qui se contenait de répondre aux succès et défauts en lecture. Le cache était un cache ''write through'' un peu particulier. En cas de succès de cache pour une écriture, le cache met à jour sa ligne de cache et propage l'écriture en mémoire RAM. Par contre, si une écriture fait un défaut de cache, la donnée n'est pas écrite dans le cache. Le seul moyen pour copier une donnée dans le cache était un défaut pour une lecture. Le 82385 pouvait commander soit un cache ''direct mapped'', soit associatif à deux voies. La différence entre les deux est que le 82385 était relié à une mémoire SRAM avec un cache ''direct mapped'', deux SRAM pour deux voies. Pour avoir un cache associatif à deux voies, le 82385 devrait gérer deux signaux ''chip select'' pour activer chaque SRAM/voie suivant les besoins. Il avait précisément quatre signaux CS : deux par SRAM, un pour les lectures, un pour les écritures. Notons que les SRAM utilisées devaient être des SRAM avec un bus de données de 32 bits, des SRAM 16 ou 8 bits ne fonctionnaient pas. Le 82385 rajoutait 4 sorties, pour masquer chaque octet dans ces 32 bits. Le choix entre les deux était le fait d'une entrée : la mettre à 0 indiquait un cache ''direct mapped'', la mettre à 1 forçait un cache à deux voies. Il gérait aussi les accès mémoire non-cacheable, à savoir des accès mémoire qui ne doivent pas être pris en compte par le cache. Il considérait certains accès mémoire comme "à ne pas cacher". Notamment, les accès mémoire à une entré-esortie ne sont pas cachés. Pour rappel, le processeur utilisait un espace d'adressage séparé pour les entrées-sorties, et utilisait donc un bit IO, qui était utilisé par le 82385 pour savoir si l'accès mémoire doit être caché ou non. Il en est de même pour les accès ayant lieu lors d'une interruption, qui ne passent pas par le cache. Mais au-delà de cette inhibition automatique du cache, le 82385 avait une entrée NCA (''Non Cacheable Access'') : le cache était "désactivé" quand cette entrée était à 1. C'est un peu une sorte de ''chip select'' pour le 82385, limitée aux accès mémoire. Cette entrée permettait de programmer des intervalles d'adresse auxquels ne pas répondre, en utilisant des circuits de décodage d'adresse adaptés. Il avait aussi une entrée X16, qui permettait d'identifier les accès soit à un composant 16 bits, soit à une entrée-sortie. De tels accès ne doivent pas être mis en cache, ce qui était garanti par cette entrée. Cette surveillance du bus permettait de gérer une forme limitée de cohérence des caches par invalidation. Dès que le 82385 détectait une prise de contrôle du bus par autre chose que le processeur, il surveillait les adresses transmises sur le bus. En cas de succès de cache, la ligne de cache associée était invalidée. Au-delà de ca, le 82385 avait une entrée FLUSH, qui ordonnait une invalidation totale du cache. Si cette entrée est mise à 1, toutes les lignes de cache sont invalidées. Les ''tags'' sont marqués comme invalides, mais les lignes de cache elles-mêmes ne sont pas touchées. Le 82385 pouvait être intégré dans un système à deux processeurs, voire plus. Pour cela, chaque processeur avait son propre 82385 et sa SRAM rien qu'à lui. Il n'y avait pas de cache partagé entre les deux processeurs. Par contre, les deux caches étaient reliés au même bus système. Pour qu'ils ne se marchent pas sur les pieds, il y avait des circuits d'arbitrage pour gérer l'accès au bus. Un des deux 82385 était mis en mode maitre, l'autre était en mode esclave. Le 82385 maitre pouvait prendre le contrôle du bus, le 82385 esclave devait demander l'autorisation au premier pour accéder au bus système. ===L'exemple des processeurs Intel de microarchitecture ''Broadwell''=== Un autre exemple est celui du cache L4 des processeurs Broadwell et de quelques processeurs séparés. Ces processeurs ont une organisation en ''chiplet'' où le processeur incorpore plusieurs puces séparées : une puce pour le processeur proprement dit, une puce nommée ''Crystal Well'' pour le cache L4, et une puce IO pour la communication avec la RAM et la carte mère. Le processeur incorporait un cache L4 de 128 mébioctets, composé de mémoire eDRAM, qui était dispersé entre ''Crystal Well'' et les autres puces. Les données du cache L4 étaient dans ''Crystal Well'', alors que les Tags étaient soit dans le processeur lui-même, soit dans la puce IO ! La puce ''Crystal Well'' était une mémoire DRAM adressable tout ce qu'il y a de plus basique, avec cependant quelques optimisations notables. Par exemple, elle avait deux bus séparés pour l'écriture et la lecture. De plus, elle avait une organisation interne avec 128 banques, contre moins d'une dizaine pour la DDR de l'époque et environ 32 banques pour la DDR5 moderne. Elle contenait aussi quelques circuits pour gérer son rôle de mémoire cache, mais rien en ce qui concerne la gestion des tags eux-mêmes. Sur les processeurs de microarchitecture ''Broadwell'', les tags étaient placés dans le CPU et précisément dans le cache L3. À chaque accès mémoire au cache L3, les tags du cache L4 étaient consultés en parallèle. De fait, l'accès au cache L4 était assez rapide, malgré le fait que les données étaient dans une puce à part. Ajoutons à cela que le processeur et ''Crystal Well'' n'avaient pas la même finesse de gravure ni la même technologie de fabrication. Les tags étaient implémentés avec de la SRAM contre la DRAM pour les données, ce qui fait que la consultation des tags était plus rapide que l'accès aux données. Par la suite, dans certains CPU de microarchitecture ''skylake'', les tags ont été déplacés en-dehors du processeur pour finir dans le contrôleur mémoire. En faisant cela, le cache L4 pouvait être utilisé par autre chose que le processeur, et notamment par la carte graphique intégrée au CPU. Avec ''broadwell'', le fait que les tags étaient consultés en cas d'accès au L3 empêchait au GPU intégré de consulter le cache L4. Mais en déplaçant les tags dans le contrôleur mémoire, ce n'est plus le cas vu que la carte graphique a aussi accès au bus mémoire. Par contre, le temps d'accès augmente comparé à la solution précédente. On n'accède pas aux tags du L4 en parallèle du L3 : à la place, il faut consulter les tags du L3, détecter un défaut de cache L3, et ensuite accèder aux tags. ===Les caches RAM-configurables=== Un autre avantage des caches splittés est qu'on peut les modifier pour servir à la fois de mémoire cache, mais aussi de ''local store'', de mémoire RAM de petite taille. Le fonctionnement est assez simple à comprendre. Lors d'un accès au cache, on accède aux tags, puis à la RAM interne au cache. Lors d'un accès au ''local store'', on contourne l'accès au tags et on accède à la RAM interne au cache directement. Il s'agit de la technique du '''cache RAM-configurable''. L'usage de cache RAM-configurable est fréquent sur les cartes graphiques récentes, qui incorporent un ou plusieurs processeurs multicoeurs, dont le cache L1 de données est un cache RAM-configurable. [[File:Hydride cache - local store.png|centre|vignette|upright=2.0|Hydride cache - local store]] ===La compression de cache=== Une autre optimisation permise par les ''phased caches'' est l'implémentation de techniques de '''compression de cache''', qui visent à compresser des lignes de cache. L'intérêt est qu'on peut stocker plus de données dans le cache, à capacité égale. L'inconvénient est qu'on doit compresser/décompresser les lignes de cache, ce qui demande un circuit en plus et allonge les temps d'accès. En effet, le temps mis pour compresser/décompresser une ligne de cache s'ajoute au temps d'accès. Aussi, la compression de cache sert surtout pour les caches de bas niveau dans la hiérarchie mémoire, les gros caches aux temps d'accès assez longs. Une première technique, assez simple à implémenter et peu couteuse en circuit, est celle de la '''compression des lignes de cache nulles'''. Elle compresse uniquement les lignes de cache qui ne contiennent que des zéros. L'idée est qu'on ajoute, dans la mémoire des tags, un bit de contrôle pour chaque ligne de cache appelé le bit ''null''. Il indique si la ligne de cache ne contient que des zéros. Quand on lit une ligne de cache, la mémoire des tags est accédée et on vérifie le bit ''null'' : s'il vaut 1, on n'accède pas à la mémoire cache de données et un multiplexeur envoie un zéro sur le port de lecture. Le bit ''null'' est fixé lors de l'écriture d'une ligne de cache : elle passe dans un comparateur avec zéro relié à la mémoire des tags. La comparaison avec zéro peut se faire en parallèle de l'écriture ou avant (dans ce cas, on n'écrit pas la ligne de cache dans le cache). Les autres techniques de compression de cache permettent de compresser autre chose que des lignes de cache nulles. L'idée est qu'une ligne de cache physique peut par moment mémoriser plusieurs lignes de caches compressées. Par exemple, prenons un cache dont les lignes de cache font 64 octets. Il est possible de compresser deux lignes de cache pour qu'elles fassent chacune 32 octets, et les stocker dans une seule ligne de cache. Les deux lignes de cache auront des tags différents, mais pointeront sur la même ligne de cache physique. Et cela demande d'utiliser un ''phased cache'' dont la mémoire pour les tags est plus grande que la mémoire pour les données. Il n'y a donc plus une bijection entre tags et ligne de cache, mais une relation surjective. Chose qui n'est possible qu'avec un ''phased cache''. De plus, des bits de contrôles associés à chaque ''tag'' indiquent où se trouvent les lignes de cache compressées dans la ligne de cache : est-ce que c'est les 32 octets de poids fort ou de poids faible ? [[File:Compression de cache.png|centre|vignette|upright=2|Compression de cache]] Il ne semble pas que les techniques de compression de cache soient implémentées sur les processeurs modernes. Aucun n'utilise de compression de cache, à ma connaissance. Il faut dire que les techniques connues sont de mauvais compromis : le temps d'accès du cache augmente beaucoup, le cout en circuit pourrait être utilisé pour un cache non-compressé mais plus grand. Et notons que la compression de cache ne marche que si les données peuvent se compresser. Si ce n'est pas le cas, une partie de la mémoire des tags est inutilisée. Une revue de la littérature académique sur la compression de cache est disponible via ce lien, pour les curieux : * [https://inria.hal.science/hal-03285041 Understanding Cache Compression, par Carvalho et Seznec]. ==L'adressage physique ou logique des caches== Le cache utilise les adresses à lire/écrire pour déterminer s'il a une copie de la donnée en son sein. Mais l’interaction entre caches et mémoire virtuelle donne lieu à un petit problème : l'adresse utilisée est-elle une adresse virtuelle/logique ou physique ? La réponse varie suivant le processeur : certains caches utilisent l'adresse virtuelle, tandis que d'autres prennent l'adresse physique. On parle de cache '''virtuellement tagué''' dans le premier cas et de cache '''physiquement tagué''' dans le second. {| |[[File:Cache tagué virtuellement.png|vignette|Cache tagué virtuellement.]] |[[File:Cache tagué physiquement.png|vignette|Cache tagué physiquement.]] |} ===L'accès à un cache physiquement/virtuellement tagué=== La manière d'accéder à un cache dépend de s'il est virtuellement ou physiquement tagué. Il faut utiliser l'adresse virtuelle pour les premiers, physique pour les seconds. Avec un cache virtuellement tagué, l'adresse logique peut être envoyée directement au cache. La MMU ne traduit les adresses que s'il faut accéder à la mémoire RAM. Ces caches sont donc plus rapides. Avec un cache physiquement tagué, le processeur doit traduire l'adresse logique en adresse physique dans la MMU, avant d'accéder au cache. La traduction d'adresse se fait soit en accédant à une table des pages en mémoire RAM, soit en accédant à un cache spécifiquement dédié à accélérer la traduction d'adresse, la TLB (''Translation Lookaside Buffer''). Dans la quasi-totalité des cas, la traduction d'adresse passe par la TLB, ce qui fait qu'elle est raisonnablement rapide. Toujours est-il que chaque accès au cache demande d'accéder à la TLB et de faire la traduction d'adresse avant d'accéder au cache. L'accès est donc plus lent que sur les caches virtuellement tagués, où les accès sont plus directs. [[File:Virtual and Physical addressing.svg|centre|vignette|upright=2|Cache tagué virtuellement versus physiquement tagué.]] ===Les défauts des caches virtuellement tagués=== Les caches physiquement tagués sont moins rapides que les caches virtuellement adressés. Pourtant, les caches virtuellement tagués sont peu fréquents sur les processeurs modernes. Et la raison est assez intéressante : c'est une question d'adresses homonymes et synonymes. ====Les droits d'accès doivent être vérifiés lors d'un accès au cache==== Un premier problème est que la protection mémoire est compliquée avec de tels caches. Rappelons que certaines portions de mémoire sont accessibles seulement en lecture, ou sont interdites en écriture, sont inexécutables, etc. Ces droits d'accès sont gérés par la MMU, qui vérifie pour chaque accès mémoire que l'accès est autorisé. En bypassant la MMU, l'accès au cache virtuellement tagué ne permet pas de faire ces vérifications. Il est possible de charger une donnée en lecture seule dans le cache, mais d'y faire des accès en écriture pour les accès ultérieurs. Les solutions à cela sont multiples. La première consiste à consulter la MMU en parallèle de l'accès au cache. L'accès au cache est alors réalisé de manière spéculative, et est ensuite confirmé/annulé une fois que la MMU a rendu son verdict. Les performances du cache restent alors les mêmes : l'accès à la MMU se fait en parallèle de l'accès au cache, pas avant. Une autre solution est d'ajouter les droits d'accès en question dans la ligne de cache, dans les bits de contrôle situés après le Tag. Chaque accès au cache récupère ces bits de contrôle et vérifie si l'accès est autorisé. L'inconvénient est que les lignes de cache deviennent plus longues, les droits d'accès sont dupliqués entre MMU et cache. Mais si le budget en transistor suit, ce n'est rien d'insurmontable. ====Les adresses homonymes perturbent la gestion du cache==== Pour rappel, une adresse logique homonyme correspond à plusieurs adresses physiques différentes. Elles surviennent quand chaque programme a son propre espace d'adressage. Dans ce cas, une adresse logique correspondra à une adresse physique différente par programme.Une autre manière de voir les choses est qu'il y a en réalité deux adresses homonymes, qui ont la même valeur, mais appartiennent à des espaces d'adressage différentes. Et c'est cette seconde interprétation que nous allons utiliser. Les caches doivent gérer ces adresses homonymes et faire en sorte que la lecture/écriture d'une adresse homonyme se fasse à la bonne adresse physique, dans la bonne ligne de cache. Et autant un cache physiquement tagué n'a aucun problème avec ça, vu qu'il ne gère que des adresses physiques, autant des problèmes surviennent avec les caches virtuellement tagués. Le problème est que les caches virtuellement tagués doivent faire la différence entre deux adresses homonymes de même valeur. Pour corriger ces problèmes, il existe deux grandes méthodes. La première méthode est simple : '''vider les caches''' en changeant de programme. Leur contenu est rapatrié en mémoire RAM, puis les caches sont remis à zéro. Le vidage du cache recopie les lignes de cache ''dirty'' (modifiées) en RAM, puis efface/invalide tout le cache. C'est à cela que servent les instructions ''clean'' et d'invalidation vues plus haut, elles ont été inventées pour cette situation précise. Lorsque le système d'exploitation déclenche une commutation de contexte, à savoir qu'il change le programme en cours d'exécution, le processeur vide tous les caches du processeur. Les interruptions font la même chose, elles vide tous les caches du processeur. Une seconde méthode numérote chaque programme en cours d'exécution, chaque processus. Le numéro attribué est spécifique à chaque processus, ce qui fait qu'il est appelé un '''identifiant de processus CPU'''. Le processeur mémorise l'identifiant du programme en cours d'exécution dans un registre dédié. L'identifiant de processus CPU est utilisé lors des accès mémoire. Chaque ligne de cache contient le numéro de l'espace d'adressage associé, dans son ''tag''. Lors de chaque accès mémoire, l'ID du registre est comparé à l'ID de la ligne de cache accédée, pour vérifier que l'accès mémoire accède à la bonne donnée. Cette méthode n'est pas très économe en termes de transistors. L'usage d'identifiant de processus CPU est clairement meilleure en termes de performance, les commutations de contexte sont plus rapides. Par contre, le budget en transistor est plus important. Un autre défaut de cette méthode est que l'identifiant de processus est généralement codé sur une dizaine de bits, alors que le système d'exploitation utilise des identifiants de processus beaucoup plus larges, de 32 à 64 bits sur les CPU 32/64 bits. L'OS doit gérer la correspondance entre identifiants de processus CPU et ceux de l'OS. Parfois, pour cette raison, les OS n'utilisent pas toujours ce système d'identifiant de processus CPU. ====Les adresses synonymes perturbent aussi la gestion du cache==== La gestion des adresses synonymes est aussi un gros problème sur les caches virtuellement tagués. Pour rappel, il s'agit du cas où des adresses logiques différentes pointent vers la même adresse physique. Typiquement, quand deux programmes se partagent un morceau de mémoire, ce morceau correspondra à des adresses synonymes dans les deux espaces d'adressage. Mais il arrive que l'on ait des adresses synonymes dans le même espace d'adressage, ce n'est pas si rare ! Autant les adresses synonymes ne posent aucun problème avec les caches physiquement tagués, ce n'est pas le cas avec les caches virtuellement adressés. Sur ces caches, deux adresses logiques synonymes vont tomber dans deux lignes de cache différentes. Corriger ce problème demande d'ajouter des circuits annexes pour détecter les adresses synonymes, qui sont vraiment complexes et ont un cout en termes de performance. Aussi, les caches virtuellement tagués sont très peu utilisés sur les processeurs modernes. ===Les caches virtuellement adressés, mais physiquement tagués=== Si les caches physiquement et virtuellement tagués ont des défauts, il existe un intermédiaire qui est un bon compromis entre ces deux extrêmes. Il s'agit des '''caches virtuellement adressés - physiquement tagués''', aussi appelés '''caches pseudo-virtuels'''. Pour comprendre comment ils fonctionnent, précisons que ces caches sont soit des caches ''direct-mapped'', soit des caches associatifs par voie (composés de plusieurs RAM ''direct-mapped'' accédées en parallèle, plusieurs voies). L'accès à ce genre de cache se fait en deux temps : on accède à un ou plusieurs RAM ''direct-mapped'' et on vérifie ensuite les ''Tags'' pour sélectionner la bonne voie. Sur les caches ''direct-mapped'', on n'a qu'une seule RAM ''direct-mapped''. Sur les caches associatifs, on a plusieurs RAM ''direct-mapped'', appelées des voies, qui sont accédées en parallèle. L'accès se fait donc en deux étapes : adresser les RAM ''direct-mapped'' avec un indice, vérifier les ''tags'' avec le reste de l'adresse. Une autre chose à rappeler est que l'adresse logique est composée de deux parties : un numéro de page logique qui indique dans quel page se situe l'adresse, un décalage/''offset'' qui indique la position de l'adresse dans la page. La traduction d'adresse transforme le numéro de page logique en numéro de page physique, mais laisse le décalage intouché. L'idée est d'utiliser le décalage pour adresser les RAM avec le décalage, tandis que le numéro de page sert de ''tag''. Le décalage est découpé en deux lors de l'accès au cache : les bits de poids fort forment l'indice (l'adresse envoyée à la voie), les bits de poids faible donnent la position de l'adresse dans la ligne de cache. L'idée est d'utiliser un numéro de page physique pour les ''tags'', mais d'adresser les voies avec le décalage logique. Les deux servent à des instants différents : vérification des ''tags'' pour l'adresse physique, accès aux voies pour l'adresse logique. Ainsi, le problème des adresses synonymes ou homonymes est résolu par l'utilisation de l'adresse physique pour les tags. Par contre, l'accès au cache est plus rapide, car on utilise l'adresse logique pour la première étape. Le processeur accède à la TLB et récupère l'adresse physique pendant que l'on adresse les voies, les deux sont faits en parallèle, ce qui fait que tout se passe comme si l'accès à la TLB était gratuit. La TLB étant assez rapide comparé au cache, l'adresse physique est disponible quand on doit faire la comparaison avec les ''tags''. [[File:Virtual - Physical - Pseudo Virtual addressing.svg|centre|vignette|upright=2|Adressage pseudo virtuel des caches.]] Il s'agit d'un excellent compromis entre performance et correction des problèmes des adresses synonymes/homonymes. Tous les caches des processeurs haute performance utilisent cette méthode, au moins pour leurs caches L1. Les caches L2 tendent à utiliser des caches physiquement adressés, pour lesquels la latence d'accès est suffisante pour qu'on accède à la TLB en amont. La raison est assez simple à expliquer, elle provient d'une contrainte assez précise sur le calcul de l'indice. La conséquence est qu'un cache ''direct-mapped'' ne peut pas dépasser la taille d'une page, soit 4 kibioctets sur les ordinateurs actuels. Sur les caches associatifs, on peut dépasser cette limite en augmentant le nombre de voies, mais la taille maximale d'une voie reste celle d'une page. Cette contrainte n'est pas trop grave sur les caches de petite taille, dont les caches L1. La plupart d'entre eux ont trouvé un compromis idéal avec moins d'une dizaine de voies par cache, chacun de 4 kibioctets, ce qui donne des caches allant de 16 à 64 kibioctets, soit entre 4 et 16 voies. Par contre, un cache de grande taille doit utiliser un grand nombre de voies, ce qui est peu pratique. Aussi, cette technique de caches pseudo-virtuels n'est pas toujours appliquée sur les caches L2, qui sont physiquement adressés. Il faut dire qu'on accède au cache L2 lors d'un défaut dans le cache L1, et l'adresse physique est disponible à ce moment-là, elle a déjà été récupérée lors de l'accès au cache L1. On peut donc l'utiliser pour adresser le cache L2 sans perte de performance. ==Le remplacement des lignes de cache== Lorsqu'un cache est rempli et qu'on charge une nouvelle donnée dedans, il faut faire de la place pour cette dernière. Dans le cas d'un cache directement adressé, il n'y a rien à faire vu que la ligne de cache à évincer est déterminée lors de la conception du cache. Mais pour les autres caches, la donnée peut aller dans n'importe quelle ligne ou voie. Or, le choix des données à rapatrier en RAM doit être le plus judicieux possible : on doit virer de préférence des données inutiles. Rapatrier une donnée qui sera surement utilisée sous peu est inutile, et il vaudrait mieux supprimer des données qui ne serviront plus ou alors dans longtemps. Il existe différents algorithmes spécialement dédiés à résoudre ce problème efficacement, directement câblés dans les unités de gestion du cache. Certains sont vraiment très complexes, aussi je vais vous présenter quelques algorithmes particulièrement simples. Mais avant de voir ces algorithmes, il faut absolument que je vous parle d'une chose très importante. Quel que soit l'algorithme en question, il choisit la ligne de cache à évincer et recopie son contenu dans la RAM. Ce qui demande d'identifier et de sélectionner une ligne de cache parmi toutes les autres. Pour cela, le circuit de remplacement attribue une adresse chaque ligne de cache ! Vous avez bien vu : chaque ligne de cache est numérotée par une adresse, interne au cache. ===Le remplacement aléatoire=== Premier algorithme : la donnée effacée du cache est choisie au hasard ! C'est contre-intuitif, mais cet algorithme donne des résultats assez honorables, en plus d'utiliser très peu de portes logiques (un générateur de nombres pseudo-aléatoire est un circuit assez simple). Généralement, les défauts de cache sont séparés par un nombre assez important et irrégulier de cycles d'horloge. Dans ces conditions, cette technique donne un bon résultat. ===FIFO : first in, first out=== Avec l'algorithme FIFO, la donnée effacée du cache est la plus ancienne, celle chargée dans le cache avant les autres. Cet algorithme est très simple à implémenter en circuit, concevoir une mémoire de type FIFO n'étant pas très compliqué, comme on l’a vu dans le chapitre dédié à ce type de mémoires. Et on peut dire que dans le cas d'un cache, l'implémentation est encore plus simple et se contente d'un seul registre/compteur. Typiquement, il suffit d'ajouter un registre qui mémorise où se situe la donnée la plus récente. Toute insertion d'une nouvelle donnée se fait à l'adresse suivante, ce qui demande juste d'incrémenter le registre avant d'utiliser son contenu pour l'accès mémoire. [[File:Algorithme FIFO de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme FIFO de remplacement des lignes de cache.]] Cet algorithme possède une petite particularité sur les caches associatifs par voie : en augmentant le nombre d'ensembles, les performances peuvent se dégrader : c'est ce qu'on appelle l''''anomalie de Bélády'''. ===MRU : most recently used=== Avec l'algorithme MRU, la donnée remplacée est celle qui a été utilisée le plus récemment. Cet algorithme s'implémente simplement avec un registre, dans lequel on place le numéro de la dernière ligne de cache utilisée. Cet algorithme de remplacement est très utile quand un programme traverse des tableaux du premier élément jusqu'au dernier : les données du tableau sont rarement réutilisées, rendant le cache inutile. Il est prouvé que dans ces conditions, l'algorithme MRU est optimal. Mais dans toutes les autres conditions, cet algorithme a des performances assez misérables. ===LFU : least frequently used=== Avec l'algorithme LFU, la donnée supprimée est celle qui est utilisée le moins fréquemment. Cet algorithme s'implémente en associant un compteur à chaque ligne de cache, qui est incrémenté à chaque accès mémoire. La ligne la moins récemment utilisée est celle dont le compteur associé a la plus petite valeur. Implémenter cet algorithme prend pas mal de transistors, car il faut rajouter autant de compteurs qu'il y a de lignes de cache, en plus d'un circuit pour comparer les compteurs et d'un encodeur. [[File:Algorithme LFU de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme LFU de remplacement des lignes de cache]] ===LRU : least recently used=== Avec l'algorithme LRU, la donnée remplacée est celle qui a été utilisée le moins récemment. Cet algorithme se base sur le principe de localité temporelle, qui stipule qu'une donnée accédée récemment a de fortes chances d'être réutilisée dans un futur proche. Et inversement, la donnée la moins récemment utilisée du cache est celle qui a le plus de chance de ne servir à rien dans le futur. Autant la supprimer en priorité pour faire de la place à des données potentiellement utiles. Implémenter l'algorithme LRU peut se faire de différentes manières, qui ont pour point commun d'enregistrer les accès au cache pour en déduire la ligne la moins récemment accédée. La manière la plus simple demande d'utiliser un compteur pour chaque ligne de mémoire cache, un peu comme le LFU. La différence avec le LFU est que le compteur n'est pas incrémenté lors d'un accès mémoire. À la place, ce compteur est incrémenté régulièrement, chaque incrémentation ayant lieu en même temps pour tous les compteurs. Quand un bloc est chargé dans le cache, ce compteur est mis à zéro. Quand une ligne de cache doit être remplacée, un circuit va vérifier la valeur de tous les compteurs : la ligne LRU (la moins récemment utilisée), est celle dont le compteur a la valeur la plus haute. Le circuit est composé d'un paquet de comparateurs, et d'un encodeur, comme pour l'agorithme LFU. ===Les approximations du LRU=== Implémenter le LRU demande un nombre de transistors proportionnel au carré du nombre de lignes de cache. Autant dire que le LRU devient impraticable sur de gros caches. Ce qui fait que les processeurs modernes implémentent des variantes du LRU, moins couteuses en transistors, qui donnent un résultat approximativement semblable au LRU. En clair, ils ne sélectionnent pas toujours la ligne de cache la moins récemment utilisée, mais une ligne de cache parmi les moins récemment utilisées. Ce n'est pas un problème si grave que cela car les lignes les moins récemment utilisées ont toutes assez peu de chance d'être utilisées dans le futur. Entre choisir de remplacer une ligne qui a 0,5 % de chances d'être utilisée dans le futur et une autre qui a une chance de seulement 1 %, la différence est négligeable en termes de taux de succès. Mais les gains en termes de circuits ou de temps d'accès au cache de ces algorithmes sont très intéressants. L'algorithme le plus simple consiste à couper le cache (ou chaque voie s'il est associatif) en plusieurs sections. L'algorithme détermine la section la moins récemment utilisée, avant de choisir aléatoirement une ligne de cache dans cette section. Pour implémenter cet algorithme, il nous suffit d'un registre qui mémorise le morceau le moins récemment utilisé, et d'un circuit qui choisit aléatoirement une ligne de cache. Cette technique s'adapte particulièrement bien avec des caches associatifs à voies : il suffit d'utiliser autant de morceaux que de voies. Autre algorithme, un peu plus efficace : le '''pseudo-LRU de type M'''. Cet algorithme attribue un bit à chaque ligne de cache, bit qui sert à indiquer de façon approximative si la ligne de cache associée est une candidate pour un remplacement ou non. Il vaut 1 si la ligne n'est pas une candidate pour un remplacement et zéro sinon. Le bit est mis à 1 lorsque la ligne de cache associée est lue ou écrite. Évidemment, au fil du temps, toutes les lignes du cache finiront par avoir leur bit à 1. Lorsque cela arrive, l'algorithme remet tous les bits à zéro, sauf pour la dernière ligne de cache accédée. L'idée derrière cet algorithme est d'encercler la ligne de cache la moins récemment utilisée au fur et à mesure des accès. L'encerclement commence lorsque l'on remet tous les bits associés aux lignes de cache à 0, sauf pour la ligne accédée en dernier. Au fur et à mesure des accès, l'étau se resserre autour de la ligne de cache la moins récemment utilisée. Après un nombre suffisant d'accès, l'algorithme donne une estimation particulièrement fiable. Et comme les remplacements de lignes de cache sont rares comparés aux accès aux lignes, cet algorithme finit par donner une bonne estimation avant qu'on ait besoin d'effectuer un remplacement. Le dernier algorithme d'approximation, le '''PLURt''', se base sur ce qu'on appelle un arbre de décision. Il a besoin de n − 1 bits pour déterminer la ligne LRU. Ces bits doivent être organisés en arbre, comme illustré plus bas. Chacun de ces bits sert à dire : le LRU est à ma droite ou à ma gauche : il est à gauche si je vaux 0, et à droite si je vaux 1. Trouver le LRU se fait en traversant cet arbre, et en interprétant les bits un par un. Au fur et à mesure des lectures, les bits sont mis à jour dans cet arbre, et pointent plus ou moins bien sur le LRU. La mise à jour des bits s'effectue lors des lectures et écritures : quand une ligne est lue ou écrite, elle n'est pas la ligne LRU. Pour l'indiquer, les bits à 1 qui pointent vers la ligne de cache sont mis à 0 lors de la lecture ou écriture. {| |[[File:Organisation des bits avec l'algorithme PLURt.jpg|vignette|Organisation des bits avec l'algorithme PLURt.]] |[[File:Ligne de cache pointée par les bits de l'algorithme.png|vignette|Ligne de cache pointée par les bits de l'algorithme.]] |} ===LRU amélioré=== L'algorithme LRU, ainsi que ses variantes approximatives, sont très efficaces tant que le programme respecte relativement bien la localité temporelle. Par contre, Le LRU se comporte assez mal dans les circonstances ou la localité temporelle est mauvaise mais où la localité spatiale est respectée, le cas le plus emblématique étant le parcours d'un tableau. Pour résoudre ce problème, des variantes du LRU existent. Une variante très connue, l''''algorithme 2Q''', utilise deux caches : un cache FIFO pour les données accédées une seule fois et un second cache LRU. Évidemment, les données lues une seconde fois sont migrées du cache FIFO vers le cache LRU, ce qui n'est pas très pratique. Les processeurs n'utilisent donc pas cette technique, mais celle-ci est utilisée dans les caches de disque dur. D'autres variantes du LRU combinent plusieurs algorithmes à la fois et vont choisir lequel de ces algorithmes est le plus adapté à la situation. Notre cache pourra ainsi détecter s’il vaut mieux utiliser du MRU, du LRU, ou du LFU suivant la situation. ==Les écritures dans le cache : gestion et optimisations== Les écritures se font à une adresse mémoire bien précise, qui peut ou non être chargée dans le cache. Si la donnée à écrire est chargée dans le cache, elle est modifiée directement dans le cache, mais elle ne l'est pas forcément en mémoire RAM. Suivant le processeur, les écritures sont ou non propagées en mémoire RAM. Il existe deux stratégies d'écritures, appelées respectivement le ''write-back'' et le ''write-through''. Avec un cache ''write-back'', si la donnée à mettre à jour est présente dans le cache, on écrit dans celui-ci sans écrire dans la mémoire RAM. Dans ces conditions, une donnée n'est enregistrée en mémoire que si celle-ci quitte le cache, ce qui évite de nombreuses écritures mémoires inutiles. [[File:Cache write-through.png|centre|vignette|upright=2|Cache write-through.]] Avec les caches '''Write-Through''', toute écriture dans le cache est propagée en RAM. Cette stratégie augmente le nombre d'écritures dans la mémoire RAM, ce qui peut saturer le bus reliant le processeur à la mémoire. Les performances de ces caches sont donc légèrement moins bonnes que pour les caches ''write back''. Par contre, ils sont utiles dans les architectures avec plusieurs processeurs, comme nous le verrons dans les chapitres sur les architectures multiprocesseurs. [[File:Cache write-back.png|centre|vignette|upright=2|Cache write-back.]] ===Les caches ''Write-through''=== Sans optimisation particulière, on ne peut écrire dans un cache ''write-through'' pendant qu'une écriture en RAM a lieu en même temps : cela forcerait à effectuer deux écritures simultanées, en comptant celle imposée par l'écriture dans le cache. Pour éviter cela, certains caches ''write-through'' intègrent un '''tampon d’écriture''', qui sert de file d'attente pour les écritures en RAM. C'est une mémoire FIFO dans laquelle on place temporairement les données à écrire en RAM, où elles attendent en attendant que la RAM soit libre. Grâce à lui, le processeur peut écrire dans un cache même si d'autres écritures sont en attente dans le tampon d'écriture. Par souci d'efficacité, des écritures à la même adresse en attente dans le tampon d’écriture sont fusionnées en une seule. Cela fait un peu de place dans le tampon d’écriture, et lui permet d'accumuler plus d'écritures avant de devoir bloquer le cache. Il est aussi possible de fusionner des écritures à adresses consécutives de la mémoire en une seule écriture en rafales. Dans les deux cas, on parle de '''combinaison d'écriture'''. Mais la technique du tampon d'écriture a cependant un léger défaut qui se manifeste dans une situation bien précise : quand le processeur veut lire une donnée en attente dans le tampon d’écriture. La première manière de gérer cette situation est de mettre en attente la lecture tant que la donnée n'a pas été écrite en mémoire RAM. On peut aussi lire la donnée directement dans le tampon d'écriture, cette optimisation portant le nom de '''''store-to-load forwading'''''. Dans tous les cas, il faut détecter le cas où une lecture accède à une donnée dans le tampon d'écriture. À chaque lecture, l'adresse à lire est envoyée au tampon d'écriture, qui vérifie si une écriture en attente se fait à cette adresse. Pour cela, le tampon d’écriture doit être un cache, dont chaque entrée mémorise une écriture. Chaque ligne de cache contient la donnée à écrire, et le tag de la ligne de cache contient l'adresse où écrire la donnée. Notons que cache d'écriture a une politique de remplacement de type FIFO, le tampon d'écriture non-optimisé étant une mémoire FIFO. ===Les caches ''Write-back''=== Les caches ''write-back'' ont beau avoir des performances supérieures à celles des caches ''write-through'', il existe des optimisations qui permettent d'améliorer leurs performances. Ces optimisations consistent à ajouter des caches spécialisés à côté du cache proprement dit. Ces caches permettent de mémoriser des données qui sont éliminées du cache par les algorithmes de remplacement de ligne cache, sans pour autant faire une écriture en RAM. En suivant la procédure habituelle de remplacement des lignes de cache, on doit rapatrier la ligne en RAM avant d'en charger une nouvelle. On peut améliorer la situation en faisant l'inverse : on charge la nouvelle ligne pendant que l'ancienne donnée est rapatriée en RAM. Ainsi, la nouvelle ligne est disponible plus tôt pour le processeur, diminuant son temps d'attente. Pour implémenter cette technique, on doit mémoriser l'ancienne ligne de cache temporairement dans un '''cache d’éviction''' (ou ''write-back buffer''). [[File:Cache d’éviction.png|centre|vignette|upright=2|Cache d’éviction]] Les caches directement adressés ou associatifs par voie possèdent aussi un tampon d’écriture amélioré. Pour limiter les défauts par conflit de ces caches, des scientifiques ont eu l'idée d'insérer un cache pour stocker les données virées du cache. En faisant ainsi, si une donnée est virée du cache, on peut alors la retrouver dans ce cache spécialisé. Ce cache s'appelle le '''cache de victime'''. Ce cache de victime est géré par un algorithme de suppression des lignes de cache de type FIFO. Petit détail : ce cache utilise un tag légèrement plus long que celui du cache directement adressé au-dessus de lui. L'index de la ligne de cache doit en effet être contenu dans le tag du cache de victime, pour bien distinguer deux adresses différentes, qui iraient dans la même ligne du cache juste au-dessus. [[File:Victim Cache Implementation Example.svg|centre|vignette|upright=1|Cache de victime.]] ===La configuration du fonctionnement du cache=== Sur de nombreux processeurs, il est possible de configurer la mémoire cache pour qu'elle fonctionne soit en mode ''write-back'', soit en mode ''write-through''. Pour cela, les processeurs modernes incorporent des '''registres de configuration du cache'''. Le terme ''registre de configuration du cache'' est assez transparent et indique bien quel est leur rôle. Ils configurent comment le cache est utilisé et permettent notamment de configurer le cache pour dire s'il doit fonctionner en mode ''write-back'' ou ''write-through''. Ils permettent aussi d'activer ou de désactiver la combinaison sur écriture. Les registres en question sont configurés soit par le BIOS, soit par le système d'exploitation. Ce sont des registres protégés, que les applications ne peuvent pas configurer, elles n'en ont pas le droit. Typiquement, ils ne sont accessibles en écriture qu'en mode noyau. Sur les processeurs x86, les registres de configuration du cache sont appelés des '''''Memory type range registers''''' (''MTRRs''). Les MTRRs sont assez nombreux, et il y a notamment une différence entre mode réel et protégé. Si vous vous souvenez des chapitres sur le mode d'adressage et la mémoire virtuelle, vous vous souvenez que les processeurs x86 incorporent plusieurs modes de fonctionnement. En mode réel, le processeur ne peut adresser qu'un mébioctet de RAM, avec un système de segmentation particulier. En mode protégé, le processeur peut adresser toute la mémoire et la segmentation fonctionne différemment, quand elle n'est pas simplement désactivée. Les MTRRs sont séparés en deux : ceux pour le mode réel, ceux pour le mode protégé. Les MTRRs fixes sont ceux qui configurent le cache en mode réel, ils étaient utilisés pour gérer l'accès au BIOS, à la mémoire VGA de la carte graphique, et quelques autres accès aux entrées-sorties basiques gérées nativement par le BIOS. Pour le mode protégé, les processeurs au-delà du 386 incorporent des MTRRs variables, qui servent pour les autres entrées-sorties en général, notamment les périphériques PCI, la mémoire vidéo de la carte graphique, et j'en passe. De nos jours, les registres de configuration du cache sont désuets et cette fonctionnalité est gérée directement par la mémoire virtuelle. La table des pages contient, pour chaque page mémoire, des bits de contrôle qui disent si la page mémoire est cacheable ou non. Le contournement de cache est alors géré par le système de mémoire virtuelle, le cache de TLB et tout ce qui va avec. ===L’allocation sur écriture=== Que faire quand une écriture modifie une donnée qui n'est pas dans le cache ? Doit-on écrire la donnée dans le cache, ou non ? Si la donnée est écrite dans le cache, on dit que le cache fait une '''allocation sur l'écriture''' (ou ''write-allocate''). Certains caches effectuent une telle allocation sur écriture, mais d'autres ne le font pas ou du moins pas systématiquement. L’allocation sur écriture peut se décliner en deux sous-catégories : le '''chargement à la demande''' et l''''écriture immédiate'''. Dans le premier cas, on charge la donnée à modifier dans le cache, et on la remplace avec la donnée écrite. Dans l'écriture immédiate, l'écriture a lieu directement dans le cache et la donnée à modifier n'est pas chargée dans le cache. Évidemment, seule une portion de la ligne de cache contient la donnée écrite (valide), et le reste contient des données invalides. Le cache doit savoir quelles sont les portions du cache qui sont valides : cela demande d'utiliser un ''sector cache''. [[File:Write-back with write-allocation.svg|centre|vignette|upright=2|Cache Write-back avec allocation sur écriture.]] Sans allocation sur écriture, l'écriture est transférée directement aux niveaux de cache inférieurs ou à la mémoire si la donnée à modifier n'est pas dans le cache. Certains caches de ce genre utilisent une petite optimisation : lors de toute écriture, ils supposent que l'écriture donnera un succès de cache. Si c'est le cas, la ligne de cache qui contient la donnée est mise à jour avec la donnée à écrire. Mais si ce n'est pas le cas, la ligne de cache est invalidée, et l'écriture est transférée directement à la mémoire ou aux niveaux de cache inférieurs. [[File:Write-through with no-write-allocation.svg|centre|vignette|upright=2|Cache Write-through sans allocation sur écriture.]] ===La cohérence des caches=== Il arrive parfois que la mémoire d'un ordinateur soit mise à jour, sans que les modifications soient répercutées dans les mémoires cache. Dans ce cas, le cache contient une donnée périmée. Or, un processeur doit toujours éviter de se retrouver avec une donnée périmée et doit toujours avoir la valeur correcte dans ses caches : cela s'appelle la '''cohérence des caches'''. Il est possible de se retrouver avec des valeurs périmées dans le cache sur les ordinateurs avec plusieurs processeurs, ou si un périphérique écrit en RAM, les modifications ne sont pas répercutées automatiquement dans les mémoires cache. Pour résoudre ce problème, on peut interdire de charger dans le cache des données stockées dans les zones de la mémoire dédiées aux périphériques. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires cache. Autre solution : utiliser le fait que les périphériques déclenchent une interruption matérielle pour laisser le contrôleur DMA accéder à la mémoire. Dans ce cas, il suffit de vider les caches à chaque interruption matérielle. Le processeur peut le faire automatiquement, ou fournir des instructions pour. ==Le ''cache bypassing'' : contourner le cache== Dans certaines situations, le cache n'est pas utilisé pour certains accès mémoire. Diverses techniques permettent en effet d'effectuer des accès mémoire qui contournent le cache, qui ne passent pas par le cache. Ils sont utilisés quand l'accès en cache fait que des instructions normales ne fonctionnent pas. Par exemple, de tels accès directs à la RAM sont notamment utilisés pour l'implémentation d'instructions atomiques, une classe d'instructions spécifiques utilisées sur les processeurs multicœurs, dont nous parlerons dans plusieurs chapitres. Mais ils sont aussi utilisés pour l'accès aux périphériques, ce que nous allons voir maintenant. ===Accéder aux périphériques demande de contourner le cache=== Pour rappel, un périphérique (au sens d'entrée-sortie) contient des registres d’interfaçage qui ont une adresse au même titre que les cases mémoire. Un périphérique peut à tout instant modifier ses registres d’interfaçage, ce qui se répercute automatiquement dans l'espace d'adressage, mais rien de tout cela n'est transmis au cache. Si les accès aux périphériques passaient par l'intermédiaire du cache, on aurait droit à des problèmes. On aurait encore une fois droit à des problèmes de cohérence des caches. Le problème est géré différemment suivant que l'on utilise un espace d'adressage séparé ou des entrées-sorties mappées en mémoire. La solution est que les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache. Cela demande d'adapter le cache et le processeur. L'implémentation exacte dépend de comment sont adressés les périphériques. Pour rappel, il y a deux solutions pour adresser les périphériques : soit les périphériques disposent d'un espace d'adressage séparé de celui de la mémoire, soit il y un espace d'adressage unique partagé entre processeur et mémoire. Les deux cas donnent des solutions différentes. Avec un espace d'adressage séparé, l'espace d'adressage des périphériques n'est pas caché : aucun accès dans cet espace d'adressage ne passe par le cache. La mémoire cache n'est utilisée que pour l'espace d'adressage des mémoires, rien d'autre. C'est de loin le cas le plus simple : il suffit de concevoir le processeur pour. Il dispose d'instructions séparées pour les accès aux registres d’interfaçage et à la RAM/ROM, les premières ne passent pas par le cache, les autres si. Avec des entrées-sorties mappées en mémoire, la même solution est utilisée, mais dans une version un peu différente. Là encore, les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache, si on veut qu'ils marchent comme ils le doivent. Cela demande d'adapter le cache et le matériel pour que accès aux périphériques mappés en mémoire contournent le cache. Des adresses, voire des zones entières de la mémoire, sont marquées comme étant non-cachables. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires caches. Là encore, le processeur doit être prévu pour : on doit pouvoir le configurer de manière à marquer certaines zones de la RAM comme non-cacheable. Reste qu'il faut marquer des régions de la RAM comme non-cacheable. Pour cela, on améliore les registres de configuration du cache, vus plus haut, afin qu'ils permettent de configurer certaines portions de la RAM pour préciser qu'elles ne doivent pas être mises en cache, qu'il faut activer le contournement de cache pour celles-ci. ===Contourner le cache pour des raisons de performance=== Il arrive que des données avec une faible localité soient chargées dans le cache inutilement. Or, il vaut mieux que ces données transitent directement entre le processeur et la mémoire, sans passer par l'intermédiaire du cache. Pour cela, le processeur peut fournir des instructions d'accès mémoire qui ne passent pas par le cache, à côté d'instructions normales. De telle instructions sont appelées des '''instructions mémoire non-temporelles'''. Non-temporelle, dans le sens : pas de localité temporelle (c.a.d que les données ne seront pas réutilisées plus tard). Mais il existe aussi des techniques matérielles, où le cache détecte à l'exécution les lectures qui gagnent à contourner le cache. La dernière méthode demande d'identifier les instructions à l'origine des défauts de cache, le processeur accédant directement à la RAM quand une telle instruction est détectée. Si une instruction d'accès mémoire fait trop de défauts de cache, c'est signe qu'elle gagne à contourner le cache. L'idée est de mémoriser, pour chaque instruction d'accès mémoire, un historique de ses défauts de cache. Il existe plusieurs méthodes pour cela, mais toutes demandent d'ajouter de quoi mémoriser l'historique des défauts de cache des instructions. L'historique est mémorisé dans une mémoire appelée la '''table d’historique des défauts de lecture''' (''load miss history table''), qui est souvent un cache. L'historique en question est, dans sa version la plus simple, un compteur de quelques bits incrémenté à chaque succès de cache et décrémenté à chaque défaut de cache, qui indique si l'instruction a en moyenne fait plus de défauts ou de succès de cache. La table associe le ''program counter'' d'une instruction mémoire à cet historique. À la première exécution d'une instruction d'accès mémoire, une entrée de cette table est réservée pour l'instruction. Lors des accès ultérieurs, le processeur récupérer les informations associées et décide s'il faut contourner le cache ou non. ==La hiérarchie mémoire des caches== [[File:Cache Hierarchy.png|vignette|Hiérarchie de caches]] On pourrait croire qu'un seul cache est largement suffisant pour compenser la lenteur de la mémoire. Hélas, les processeurs sont devenus tellement rapides que les caches sont eux-mêmes très lents ! Pour rappel, plus une mémoire peut contenir de données, plus elle est lente. Et les caches ne sont pas épargnés. Si on devait utiliser un seul cache, celui-ci serait très gros et donc trop lent. La situation qu'on cherche à éviter avec la mémoire RAM revient de plus belle. Même problème, même solution : si on a décidé de diviser la mémoire principale en plusieurs mémoires de taille et de vitesse différentes, on peut bien faire la même chose avec la mémoire cache. Depuis environ une vingtaine d'années, un processeur contient plusieurs caches de capacités très différentes : les caches L1, L2 et parfois un cache L3. Certains de ces caches sont petits, mais très rapides : c'est ceux auxquels on va accéder en priorité. Viennent ensuite d'autres caches, de taille variable, mais plus lents. Les processeurs ont donc une hiérarchie de caches qui se fait de plus en plus complexe avec le temps. Cette hiérarchie est composée de plusieurs niveaux de cache, qui vont des niveaux inférieurs proches de la mémoire RAM à des niveaux supérieurs proches du processeur. Plus on monte vers les niveaux supérieurs, plus les caches sont petits et rapides. Un accès mémoire dans une hiérarchie de cache fonctionne comme suit : on commence par vérifier si la donnée recherchée est dans le cache le plus rapide, à savoir le cache L1. Si c'est le cas,n on la charge depuis ce cache directement. Si elle n’y est pas, on vérifie si elle est dans le cache de niveau supérieur, le cache L2. Et rebelote ! Si elle n'y est pas, on vérifie le cache du niveau supérieur. Et on répète cette opération, jusqu’à avoir vérifié tous les caches. Si la donnée n'est dans aucun cache, on doit alors aller chercher la donnée en mémoire. [[File:Hiérarchie de caches.png|centre|vignette|upright=2|Hiérarchie de caches]] Il y a des différences assez notables entre chaque niveau de cache. Par exemple, les différents niveaux de cache n'ont pas forcément les mêmes politiques de remplacement des lignes de cache. Le cache L1 a généralement une politique de remplacement simple, très rapide, mais peu efficace. De même, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Le cache le plus proche de la mémoire est appelé le '''cache de dernier niveau''', ''Last Level Cache'' en anglais. Il a parfois des caractéristiques totalement différentes des autres caches. Par exemple, sur les processeurs multicoeurs, le cache L3 n'est techniquement pas dans le cœur, mais fait partie d'un ensemble de circuits reliés, comme le contrôleur mémoire ou l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc. ===Les caches exclusifs et inclusifs=== Notons que du point de vue de cette vérification, il faut distinguer les caches inclusifs et exclusifs. Avec les caches inclusifs, si une donnée est présente dans un cache, alors elle est présente dans les caches des niveaux inférieurs, ce qui implique l'existence de données en doublon dans plusieurs niveaux de cache. À l'opposé, les caches exclusifs font que toute donnée est présente dans un seul cache, pas les autres. Il existe aussi des caches qui ne sont ni inclusifs, ni exclusifs. Sur ces caches, chaque niveau de cache gère lui-même ses données, sans se préoccuper du contenu des autres caches. Pas besoin de mettre à jour les niveaux de cache antérieurs en cas de mise à jour de son contenu, ou en cas d'éviction d'une ligne de cache. La conception de tels caches est bien plus simple. Dans les '''caches exclusifs''', le contenu d'un cache n'est pas recopié dans le cache de niveau inférieur. Il n'y a pas de donnée en double et on utilise 100 % de la capacité du cache, ce qui améliore le taux de succès. Par contre, le temps d'accès est un peu plus long. La raison est que si une donnée n'est pas dans le cache L1, on doit vérifier l'intégralité du cache L2, puis du cache L3. De plus, assurer qu'une donnée n'est présente que dans un seul cache nécessite aux différents niveaux de caches de communiquer entre eux pour garantir que l'on a pas de copies en trop d'une ligne de cache, ce qui peut prendre du temps. [[File:Caches exclusifs.png|centre|vignette|upright=2|Caches exclusifs]] Dans le cas des '''caches inclusifs''', le contenu d'un cache est recopié dans les caches de niveau inférieur. Par exemple, le cache L1 est recopié dans le cache L2 et éventuellement dans le cache L3. Ce genre de cache a un avantage : le temps d'accès à une donnée est plus faible. La raison est qu'il ne faut pas vérifier tout un cache, mais seulement la partie qui ne contient pas de donnée en doublon. Par exemple, si la donnée voulue n'est pas dans le cache L1, on n'est pas obligé de vérifier la partie du cache L2 qui contient la copie du L1. Ainsi, pas besoin de vérifier certaines portions du cache, ce qui est plus rapide et permet de simplifier les circuits de vérification. En contrepartie, l'inclusion fait que qu'une partie du cache contient des copies inutiles, comme si le cache était plus petit. De plus, maintenir l'inclusion est compliqué et demande des circuits en plus et/ou des échanges de données entre caches. [[File:Caches inclusifs.png|centre|vignette|upright=2|Caches inclusifs]] Maintenir l'inclusion demande de respecter des contraintes assez fortes, ce qui ne se fait pas facilement. Premièrement, toute donnée chargée dans un cache doit aussi l'être dans les caches de niveau inférieur. Ensuite, quand une donnée est présente dans un cache, elle doit être maintenue dans les niveaux de cache inférieurs. De plus, toute donnée effacée d'un cache doit être effacée des niveaux de cache supérieurs : si une donnée quitte le cache L2, elle doit être effacée du L1. Ces trois contraintes posent des problèmes si chaque cache décide du remplacement des lignes de cache en utilisant un algorithme comme LRU, LFU, MRU, ou autre, qui utilise l'historique des accès. En effet, dans ce cas, le cache décide de remplacer les lignes de cache selon l'historique des accès, historique qui varie suivant chaque niveau de cache. Par exemple, une donnée rarement utilisée dans le L2 peut parfaitement être très fréquemment utilisée dans le L1 : la donnée sera alors remplacée dans le L2, mais sera maintenue dans le L1. On observe aussi des problèmes quand il existe plusieurs caches à un seul niveau : chaque cache peut remplacer les lignes de cache d'une manière indépendante des autres caches du même niveau, donnant lieu au même type de problème. Pour maintenir l'inclusion, les caches doivent se transmettre des informations qui permettent de maintenir l'inclusion. Par exemple, les caches de niveaux inférieurs doivent prévenir les niveaux de cache supérieurs quand ils remplacent une ligne de cache. De plus, toute mise à jour dans un cache doit être répercutée dans les niveaux de cache inférieurs et/ou supérieurs. On doit donc transférer des informations de mise à jour entre les différents niveaux de cache. Généralement, le contenu des caches d'instruction n'est pas inclus dans les caches de niveau inférieurs, afin d'éviter que les instructions et les données se marchent sur les pieds. Enfin, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Dans ce cas, l'inclusion est plus difficile à maintenir, pour des raisons assez techniques. ===Les caches eDRAM, sur la carte mère et autres=== D'ordinaire, les mémoires caches sont intégrées au processeur, à savoir que cache et CPU sont dans le même circuit imprimé. Les caches sont donc fabriqués avec de la SRAM, seule forme de mémoire qu'on peut implémenter dans un circuit intégré. Intégrer tous les caches dans le processeur est une solution et efficace. Mais certains processeurs ont procédé autrement. [[File:Cache-on-a-stick module.jpg|vignette|Cache-on-a-stick module]] Des processeurs assez anciens incorporaient un cache L1 dans le processeur, mais plaçaient un cache L2 sur la carte mère. Le cache était clippé sur un connecteur sur la carte mère, un peu comme le sont les barrettes de mémoire. On parlait alors de '''''Cache on a stick''''' (COAST). On aurait pu s'attendre à ce que de tels caches soient en DRAM, vu qu'ils sont placés sur des barrettes de RAM, mais la ressemblance avec la mémoire RAM principale s'arrête là. Le cache était fabriqué en mémoire SRAM, même s'il est en théorie possible de faire de tels caches avec de la DRAM. Les premiers processeurs avec un cache faisaient ainsi, au début des années 90. Il a été introduiot sur les processeurs Motorola, et a été utilisé sur les IBM PC et les Macintosh de l'époque. Les ordinateurs Macintosh utilisaient de tels caches, pour la pluaprt des modèles. Pour ce qui est des PC, les premiers processeurs x86 faisaient pareil, notamment les processeurs Intel. Le 486, le Pentium et le Pentium 2 utilisaient des ''Cache on a stick''. L'avantage est que cela permettait de mettre plus de cache, à une époque où les circuits étaient limités en transistors. De plus, cela permettait au consommateur de choisir quelle quantité de cache il voulait, selon ses finances. Il était possible de laisser le processeur fonctionner soit sans mémoire cache, soit avec un cache de 256 Kibioctets, de 512 Kibioctets, etc. Il était possible d'upgrader le cache si besoin. Pour les CPU Intel, le cache était connecté sur le bus système, au même titre que la mémoire RAM et les entrées-sorties. Il faut dire que les processeurs de l'époque utilisaient un bus système et n'avaient pas de bus mémoire dédié. Mais en théorie, rien n’empêche de connecter le cache sur un bus mémoire dédié. Toujours est-il que les lectures et écritures étaient propagées à la fois dans le cache et la RAM. Les écritures se faisaient dans les deux, systématiquement dans la RAM, mais aussi dans le cache en cas de succès de cache. Les lectures étaient servies soit par le cache en cas de succès de cache, soit par la RAM en cas de défaut de cache. Si le cache répondait en premier, la transaction sur le bus se terminait précocement et l'accès en RAM était abandonné. [[File:Intel486 Иерархия памяти.png|centre|vignette|upright=2.5|Intel486 : le cache était connecté sur le bus système.]] À l'inverse, certains processeurs possédaient un cache fabriqué en mémoire DRAM, et plus précisément avec de la mémoire eDRAM. Le cache n'était pas intégré dans le même circuit imprimé que le processeur, mais profitait d'une architecture en ''chiplet''. Pour rappel, cela veut dire que le processeur est en réalité composé de plusieurs circuits intégré séparés, mais interconnectés et soudés sur un même PCB carré. Avec un cache en eDRAM, le cache avait son propre circuit intégré, séparé du circuit intégré du processeur ou du circuit intégré pour le contrôleur mémoire/IO. Un exemple est celui du cache des processeurs Intel de microarchitecture Broadwell, vus dans ce chapitre dans la section sur les caches splittés. Les tags étaient intégrés dans le circuit intégré du processeur, mais les données étaient mémorisées dans une puce d'eDRAM séparée. La puce eDRAM correspondait en réalité à une DRAM adressable qui servait de DRAM pour les données et mémorisaient les voies du cache. ==Les caches adressés par somme et hashés== Les caches adressés par somme sont optimisés pour incorporer certains calculs d'adresse directement dans le cache lui-même. Pour rappel, certains modes d'adressage impliquent un calcul d'adresse, qui ajoute une constante à une adresse de base. Généralement, l'adresse de base est l'adresse d'un tableau ou d'une structure, et la constante ajoutée indique la position de la donnée dans le tableau/la structure. Les caches hashés et les caches adressés par somme permettent de faire l'addition directement dans la mémoire cache. Voyons d'abord les caches hashés, avant de passer aux caches adressés par somme. Sur les '''caches hashés''', l'addition est remplacée par une autre opération, par exemple des opérations bit à bit du style XOR, AND ou OR, etc. Seulement, utiliser des opérations bit à bit pose un problème : il arrive que deux couples Adresse/décalage donnent le même résultat. Par exemple, le couple Adresse/décalage 11101111/0001 donnera la même adresse que le couple 11110000/0000. Dit autrement, deux adresses censées être différentes (après application du décalage) sont en réalité attribuées à la même ligne de cache. Il est toutefois possible de gérer ces situations, mais cela demande des astuces de haute volée pour faire fonctionner la mémoire cache correctement. Sur les '''caches adressés par somme''', le décodeur est modifié pour se passer de l'addition. Pour comprendre comment, il faut rappeler qu'un décodeur normal est composé de comparateurs, qui vérifient si l'entrée est égale à une constante bien précise. Sur un cache ordinaire, l'addition est faite séparément du décodage des adresses par le cache, dans l'unité de calcul ou dans l'unité de génération d'adresse. [[File:Non sum adressed cache.png|centre|vignette|upright=2|Cache normal.]] Mais les caches adressés par somme modifient le décodeur, qui est alors composé de comparateurs qui testent si la somme adresse + décalage est égale à une constante. [[File:Cache adressé par somme.png|centre|vignette|upright=2|Cache adressé par somme.]] Chaque circuit du décodeur fait le test suivant, avec K une constante qui dépend du circuit : : <math>A + B = K</math> Ce qui est équivalent à faire le test suivant : : <math>A + B - K = 0</math> En complément à deux, on a <math>- K = \overline{K} + 1</math>. En injectant dans l'équation précédente, on a : : <math>A + B + \overline{K} + 1 = 0</math> En réorganisant les termes, on a : : <math>A + B + \overline{K} = - 1</math> Il suffit d'utiliser un additionneur ''carry-save'' pour faire l'addition des trois termes. Rappelons qu'un tel additionneur fournit deux résultats en sortie : une somme calculée sans propager les retenues et les retenues en question. Notons que les retenues sont à décaler d'un cran, vu qu'elles sont censées s'appliquer à la colonne suivante. En notant la somme S et les retenues R, on a: : <math>S + (R << 1) = - 1 </math>, le décalage d'un cran à gauche étant noté <math><< 1</math>. Ensuite, -1 est codé avec un nombre dont tous les bits sont à 1 en complément à un/deux. : <math>S + (R << 1) = 111 \cdots 111111</math> [[File:Sum + retenue add.png|centre|vignette|upright=2|Sum + retenue add]] Un simple raisonnement nous permet de savoir si le résultat est bien -1, sans faire l'addition <math>S + (R << 1)</math>. En effet, on ne peut obtenir -1 que si la somme est l'inverse des retenues : un 0 dans le premier nombre correspond à un 1 dans l'autre, et réciproquement. En clair, on doit avoir <math>\overline{S} = R << 1</math>. Pour vérifier cela, il suffit de faire un simple XOR entre la somme et les retenues décalées d'un cran. On a alors : : <math>S \oplus (R << 1) = 111 \cdots 111111</math> La comparaison avec -1 se fait avec une porte ET à plusieurs entrées. En effet, la porte donnera un 1 seulement si tous les bits d'entrée sont à 1, ce qui est ce qu'on veut tester. Au final, l'additionneur pour l'addition adresse + décalage est remplacé par un additionneur carry-save suivi d'une couche de portes XOR et d'un comparateur avec une constante, ce qui économise de circuits et améliore les performances. [[File:Final circuit of sum addressed cache.png|centre|vignette|upright=2|Cache adressé par somme.]] En prenant en compte que la constante K est justement une constante, certaines entrées de l'additionneur carry-save sont toujours à 0 ou à 1, ce qui permet quelques simplifications à grand coup d’algèbre de Boole. Chaque additionneur complet qui compose l’additionneur carry-save est remplacée par des demi-additionneurs (ou par un circuit similaire). Autant dire que l'on gagne tout de même un petit peu en rapidité, en supprimant une couche de portes logiques. Le circuit de décodage économise aussi des portes logiques, ce qui est appréciable. ==Les caches à accès uniforme et non-uniforme== Intuitivement, le temps d'accès au cache est le même pour toutes les lignes de cache. Il s'agit de cache appelés '''caches à accès uniforme''', sous-entendu à temps d'accès uniforme. Mais sur les caches de grande capacité, il arrive souvent que le temps de propagation des signaux varie fortement suivant la ligne de cache à lire. D'ordinaire, on se cale sur la ligne de cache la plus lente pour caler la fréquence d'horloge du cache, même si on pourrait faire mieux. Cependant, les '''caches à accès non uniforme''' ont une latence différente pour chaque ligne d'un même cache. Certaines lignes de cache sont plus rapides que d'autres. Niveau terminologie, nous allons parler de caches UCA et NUCA : ''Uniform Access Cache'' pour les caches à accès uniforme, ''Non-Uniform Access Cache'' pour les caches à accès non-uniforme. [[File:Caches UCA et NUCA.png|vignette|Caches UCA et NUCA.]] Les caches NUCA et UCA sont souvent composés de plusieurs banques séparées, typiquement une par voie. Sur les caches UCA, les banques sont interconnectées avec le processeur de manière à ce que toutes les interconnexions ont la même longueur pour toutes les banques. Typiquement, les banques sont organisées en carré, avec les interconnexions qui partent du centre, avec une disposition en H, illustrée ci-contre Mais avec les caches NUCA, ce n'est pas le cas. Les interconnexions sont simplifiées et ont des longueurs différentes. Les caches NUCA n'ont pas tous le même genre d'interconnexions, qui dépendent du cache NUCA. En général, les interconnexion forme un réseau avec des sortes de routeurs qui redirigent les données/commandes vers la bonne destination : cache ou processeur. Les banques plus proches du processeur sont accessibles plus rapidement que celles éloignées, même si la différence n'est pas énorme. Les caches NUCA sont généralement associatifs par voie. Les plus simples utilisent une banque par voie pour le cache, ce qui fait que certaines voies répondent plus vite que les autres. La détection des succès de cache est alors plus rapide si la donnée lue/écrite est dans une voie/banque rapide. En théorie, les défauts de cache demandent de vérifier toutes les banques, et se calent donc sur la pire latence. Mais divers caches se débrouillent pour que ce ne soit pas le cas, soit en vérifiant les banquyes unes par une, soit par un mécanisme de recherche plus complexe. Les caches NUCA sont surtout utilisés pour les caches L3 et L4, éventuellement les caches L2. Les caches L1 sont systématiquement des caches UCA, car la latence de l'accès au cache L1 est utilisée par le processeur pour décider quand lancer les instructions. Pour simplifier, le processeur peut démarrer en avance une instruction avant qu'une opérande soit lue dans le cache L1, de manière à ce que la donnée arrive en entrée de l'ALU pile en même temps que l'instruction. Une histoire d'exécution dans le désordre et d'émission anticipée des instructions qu'on détaillera dans une bonne dizaine de chapitres. Toujours est-il que tout est plus simple pour le processeur si le cache L1 a un temps d'accès fixe. Par contre, les caches L3 et L4 sont traités en attendant que les données arrivent, le processeur reprend l'exécution des instructions quand les caches L3 et L4 ont terminé de répondre, pas avant. Avec l'association une banque = une voie, la correspondance ligne de cache → bloc de mémoire qui est statique : on ne peut pas déplacer le contenu d'une ligne de cache dans une autre portion de mémoire plus rapide suivant les besoins. Mais la recherche académique a étudié le cas où la correspondance entre une ligne de cache et une banque varie à l’exécution. Pour nommer cette distinction, on parle de caches S-NUCA (''Static NUCA'') et D-NUCA (''Dynamic NUCA''). Intuitivement, on s'attend à ce que les caches D-NUCA soient plus performants que les caches S-NUCA. Les lignes de cache les plus utilisées peuvent migrer dans une banque rapide, alors que les lignes de cache moins utilisées vont dans une banque éloignée. Les lignes de cache se répartissent dans le cache dynamiquement dans les banques où elles sont le plus adaptées. Mais paradoxalement, le gain des caches D-NUCA est presque nul, voire insignifiant. La raison est que les caches D-NUCA doivent incorporer un système pour déterminer dans quelle banque se situe la donnée pour détecter les succès/défauts de cache, ainsi qu'un système pour migrer les données entre banques. Et ce système augmente le temps d'accès au cache, réduisant à néant l'intérêt d'un cache D-NUCA. Si on économise quelques microsecondes de temps d'accès en passant d'un cache UCA à un cache S-NUCA, ce n'est pas pour les perdre en passant à un D-NUCA. La majorité des caches D-NUCA sont donc en cours de recherche, mais ne sont pas utilisés en pratique. ==La tolérance aux erreurs des caches== Une mémoire cache reste avant tout une mémoire RAM, bien que ce soit de la SRAM. Elle n'est pas parfaite et est donc sujette à des erreurs, qui peuvent inverser un bit ou l'effacer. De telles erreurs sont liées à des rayons cosmiques très énergétiques, à des particules alpha produites par le packaging ou le métal deu circuit intégré, peu importe : l'essentiel est qu'ils inversent parfois un bit. Les mémoires modernes savent se protéger contre de telles erreurs, en utilisant trois moyens. ===Les mémoires caches ECC et à bit de parité=== Le premier moyen est l'usage de codes correcteurs d'erreurs, qui ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Les bits ajoutés dépendent de la donnée mémorisée dans le byte, et servent à détecter une erreur, éventuellement à la corriger. Le cas le plus simple ajoute un simple bit de parité pour chaque byte et se contente de détecter les erreurs dans les corriger. Les autres codes ECC permettent eux de corriger des erreurs, mais ils demandent d'ajouter au moins deux bits par byte, ce qui a un cout en circuit plus élevé. Un simple bit de parité permet de détecter qu'un bit a été inversé, mais ne permet pas de corriger l'erreur. En soi, ce n'est pas un problème. Si une erreur est détectée, on considère que la ligne de cache est invalide. Le cache gère la situation comme un défaut de cache et va chercher la donnée valide en mémoire RAM. Le cout en circuits est donc faible, mais les défauts de cache sont plus nombreux. Les codes ECC sont eux capables de corriger les erreurs, si elles ne modifient pas trop de bits d'un coup. Par contre, ils utilisent deux à trois bits par octet, ce qui a un cout en circuits loin d'être négligeable. Il y a donc un compromis entre défauts de cache et cout en circuits. La gestion de l'ECC est différente suivant le niveau de cache. Généralement, le cache L1 n'utilise pas l'ECC mais se contente d'un simple bit de parité pour éviter la corruption de ses données. Le cache étant petit, les corruptions de données sont assez rares, et les défauts de cache induits faibles. Il est plus important d'utiliser un code de détection d'erreur simple, rapide, qui ne ralentit pas le cache et n'augmente pas sa latence. Si une ligne de cache est corrompue, il a juste à aller lire la ligne depuis le cache L2, ou un niveau de cache inférieur. Du moins, c'est possible sur le cache en question est un cache inclusif et/ou ''write-through''. Par contre, le niveau de cache L2 et ceux en-dessous utilisent presque systématiquement une mémoire SRAM ECC. La raison principale étant que ce sont des caches assez gros, pour lesquels la probabilité d'une erreur est assez élevée. Plus une mémoire a de bits et prend de la place, plus il y a une chance élevée qu'un bit s'inverse. Et vu que les caches L2/L3/L4 sont par nature plus lents et plus gros, ils peuvent se permettre le cout en performance lié à l'ECC, idem pour le cout en circuit. Sans compter qu'en cas d'erreur, ils doivent aller lire la ligne de cache originelle en mémoire RAM, ce qui est très lent ! Mieux vaut corriger l'erreur sur place en utilisant l'ECC. ===L'usage du ''memory scrubbing'' sur les caches=== La plupart des erreurs ne changent qu'un seul bit dans un byte, mais le problème est que ces erreurs s'accumulent. Entre deux accès à une ligne de cache, il se peut que plusieurs erreurs se soient accumulées, ce qui dépasse les capacités de correction de l'ECC. Dans ce cas, il existe une solution appelée le ''memory scrubbing'', qui permet de résoudre le problème au prix d'un certain cout en performance. Pour rappel, l'idée est de vérifier les lignes de caches régulièrement, pour éviter que les erreurs s'accumulent. Par exemple, on peut vérifier chaque ligne de cache toutes les N millisecondes, et corriger une éventuelle erreur lors de cette vérification. En faisant des vérifications régulières, on garantir que les erreurs n'ont pas le temps de s'accumuler, sauf en cas de malchance avec des erreurs très proches dans le temps. Il ne s'agit pas d'un rafraichissement mémoire, car les SRAM ne s'effacent pas), mais ça a un effet similaire. Et évidemment, le ''memory scrubbing'' a un cout en performance. On peut faire une comparaison avec le rafraichissement mémoire : les rafraichissement réguliers réduisent les performances, car cela fait des accès en plus. Des accès qui sont de plus timés à des instants bien précis qui ne sont pas forcément les plus adéquats. Il est possible qu'un rafraichissement ait lieu en même temps qu'un accès mémoire et le rafraichissement a la priorité, ce qui réduit les performances. La même chose arrive avec les vérifications du ''memory scrubbing''. Malgré tout, la technique a été utilisée sur les caches de certains processeurs commerciaux, dont des processeurs AMD Athlon et Athlon 64. Elle est surtout utilisable sur les caches L2/L3, pour lesquels le cout du pseudo-rafraichissement est acceptable. ==Un exemple de cache : le cache d'instruction== La grande majorité des processeurs utilise deux caches L1 séparés : un '''cache d'instructions''' dédié aux instructions, et un autre pour les données. Une telle organisation permet de charger une instruction tout en lisant une donnée en même temps. Notons que seul le cache L1 est ainsi séparé entre cache de données et d'instructions. Le cache d’instruction se situe en théorie entre l'unité de chargement et l'unité de décodage. En effet, ce cache prend en entrée une adresse et fournit une instruction. L'adresse est fournie par le ''program counter'', l'instruction est envoyée dans l'unité de décodage. Le cache se situe donc entre les deux. Le cache de données L1 est connecté au chemin de données, et notamment aux unités de communication avec la mémoire, pas au séquenceur. [[File:Caches L1 et positions dans le processeur.png|centre|vignette|upright=2.5|Caches L1 et positions dans le processeur]] Les deux caches sont reliés au processeur par des bus séparés, l'ensemble ressemble à une architecture Harvard, mais où les caches remplacent les mémoires RAM/ROM. Le cache d'instruction prend la place de la mémoire ROM et le cache de données prend la place de la mémoire RAM. Évidemment, il y a des niveaux de caches en dessous des caches de données/instruction, et ceux-ci contiennent à la fois données et instructions, les deux ne sont pas séparées dans des mémoires/caches séparés. Raison pour laquelle l'ensemble est appelé une '''architecture Harvard modifiée'''. Architecture Harvard, car l'accès aux données et instructions se font par des voies séparées pour le processeur, modifiée car la séparation n'est effective que pour le cache L1 et pas les autres niveaux de cache, et encore moins la RAM. Sur les processeurs modernes, il arrive très souvent que le processeur doive charger une instruction et lire/écrire une donnée en même temps. Et à vrai dire, c'est la règle plus que l'exception. L'usage d'une architecture Harvard modifiée permet cela très facilement : on peut accéder au cache d'instruction via un bus, et au cache de donnée avec l'autre ===Pourquoi scinder le cache L1 en cache d'instruction et de données=== L'usage d'un cache d’instruction séparé du cache de données est à contraster avec l'usage d'un cache L1 multiport unique, capable de mémoriser à la fois instructions et données. Les deux solutions sont possibles ont été utilisées. Les premiers processeurs avaient un cache L1 unique et multiport, mais ce n'est plus le cas sur les processeurs modernes, car les contraintes ne sont pas les mêmes. Le compromis à faire est celui entre deux petits caches rapides et un gros cache plus lent. Pour rappel, plus un cache est petit, plus il est rapide et chauffe moins. Donc au lieu d'utiliser, par exemple, un gros cache lent de 64 Kibioctets, on utilise deux caches de 32 kibioctets, plus rapides. La capacité totale est la même, mais le temps d'accès plus faible. En termes de temps d'accès, la meilleure solution est celle des deux caches simple port. Mais pour ce qui est de l'économie de circuits, c'est moins évident. Entre deux mémoires simple port et une mémoire multiport, la différence en termes de transistors est ambigüe et dépend de la capacité des caches. La différence est surtout notable pour les gros caches, moins pour les petits caches. Il faut aussi tenir compte de la capacité effective. Avec deux caches séparés, la répartition de la capacité du cache L1 est fixée une bonne fois pour toutes. Par exemple, avec un cache d'instruction de 32 KB et un cache de données de 32 KB, impossible d'allouer 40 KB aux données et 20 aux instructions. Alors qu'avec un cache L1 unique de 64 KB, on pourrait le faire sans soucis. La répartition se fait naturellement, en fonction de la politique de remplacement du cache et est proche de l'optimal. C'est là un désavantage des caches d'instructions/données séparés : une capacité effective moindre. Tout cela explique pourquoi le cache L1 est le seul à être ainsi scindé en deux, avec une séparation entre instructions et données : les contraintes au niveau du cache L1 et L2 ne sont pas les mêmes. Pour les caches L1, le temps d'accès est plus important que la capacité, ce qui favorise les caches séparés. Par contre, pour les caches L2/L3/L4, le temps d'accès n'est pas déterminant, alors que la capacité effective et l'économie en circuits sont significatives. ===La connexion des caches L1 avec le cache L2=== Pour les connexions avec le cache L2, tout dépend du processeur. Certains utilisent un cache L2 multiport, qui permet aux deux caches L1 de lire ou écrire dans le cache L2 simultanément. [[File:Cache d'instructions.png|centre|vignette|upright=1.5|Cache d'instructions.]] Si le cache L2 ne gère pas les accès simultanés, il n'y a qu'un seul bus relié aux caches L1 et au cache L2. On doit effectuer un arbitrage pour décider quel cache a la priorité, chose qui est réalisé par un circuit d'arbitrage spécialisé. [[File:Circuit d'arbitrage du cache.png|centre|vignette|upright=1.5|Circuit d'arbitrage du cache.]] Généralement, les caches d'instructions peuvent se permettre d'être plus petits que les caches de données, car les programmes sont souvent plus petits que les données manipulées. Songez que des programmes de quelques mébioctets peuvent parfois remplir la RAM avec plusieurs gibioctets de données. Lancez votre navigateur internet et ouvrez une page web un peu chargée, pour vous en convaincre ! ===Les spécificités du cache d'instruction : lecture seule, bloquant, etc=== Les instructions sont rarement modifiées ou accédées en écritures, contrairement aux données. Et cela permet d'utiliser un cache simplifié pour les instructions. Autant un cache généraliste doit permettre les lectures et écritures depuis le processeur (avec les échanges avec la RAM), autant un cache d'instruction peut se contenter des lectures provenant du CPU et des échanges avec la RAM. Le cache d'instructions est donc très souvent en « lecture seule » : le processeur ne peut pas écrire dedans, mais juste le lire ou charger des instructions dedans. Un cache d'instruction est donc plus simple qu'un cache pour les données : on peut retirer les circuits en charge de l'écriture (mais on doit laisser un port d'écriture pour charger les instructions dedans). Le gain en circuits permet d'utiliser un cache d'instruction plus gros ou au contraire de laisser de la place pour le cache de données. Le gain en termes de capacité compense alors un peu les inconvénients des caches séparés. Par contre, cela complique la gestion du code automodifiant, c'est-à-dire des programmes dont certaines instructions vont aller en modifier d'autres, ce qui sert pour faire de l'optimisation ou est utilisé pour compresser ou cacher un programme (les virus informatiques utilisent beaucoup de genre de procédés). Quand le processeur exécute ce genre de code, il ne peut pas écrire dans ce cache L1 d'instructions, mais doit écrire dans le cache L2 ou en RAM, avant de recharger les instructions modifiées dans le cache L1. Cela qui prend du temps et peut parfois donner lieu à des erreurs si le cache L1 n'est pas mis à jour. Les algorithmes de remplacement des lignes de cache optimaux pour les données ne le sont pas pour les instructions, de même que la taille optimale du cache, la taille des lignes de cache optimale, ou même les algorithmes de préchargement. Par exemple, pour le remplacement des lignes de cache, un simple algorithme LRU est presque optimal pour les instructions, autant il peut donner de mauvaises performances quand on manipule beaucoup de tableaux. Cela justifie d'utiliser des caches spécialisés pour chacune. On peut adapter le cache d'instruction à son contenu, ce qui le rend plus rapide ou plus petit à performance égale. Les caches d'instructions sont généralement des caches bloquants. Il ne servirait à rien de rendre un cache d'instruction non-bloquant, le cout en circuits ne se traduirait pas par une augmentation significative des performances. À l'opposé, les caches de données sont non-bloquants sur les architectures modernes, pour des raisons de performance. Ce qui rend la séparation assez intéressante, les deux caches ayant des besoins différents et des implémentations différentes, cela permet d'optimiser le cout en transistors des caches. ===L'impact du cache d'instruction sur les performances=== Sur les architectures conventionnelles, le cache d'instruction a plus d'impact sur les performances que le cache de données. La raison principale est que les instructions ont une meilleure localité spatiale et temporelle que pour les données. Pour la localité spatiale, les instructions consécutives se suivent en mémoire, alors que rien ne garantit que des données utilisées ensemble soient regroupées en mémoire. Pour localité temporelle, elle est très variable pour les données, mais très courante pour les instructions du fait de l'usage fréquent des boucles et des fonctions. : La présence de branchements atténue la localité temporelle des instruction, sauf que la majorité des branchements sautent à un endroit très proche, seuls les appels de fonction brisent la localité spatiale. La conséquence est qu'il arrive que certains CPU aient un cache L1 d'instruction plus gros que celui pour les données. On parle alors de '''cache L1 asymétriques'''. Un exemple est celui des processeurs AMD de microarchitecture Zen, dont le cache d'instruction était deux fois plus gros que le cache de données. Leur cache d'instruction faisait 64 kibioctets, contre seulement 32 pour le cache de données. D'ailleurs, il existe des processeurs assez extrêmes qui se contentent d'un cache d'instruction unique, sans cache de données. C'est le cas sur les processeurs vectoriels ou les GPU que nous verrons dans les chapitres de fin de ce wikilivres. De tels processeurs sont spécialisés dans la manipulation de tableaux de données, traitement qui a une faible localité temporelle. En conséquence, utiliser un cache de données n'est pas vraiment utile, voire peu être contreproductif, alors qu'un cache d’instruction fonctionne parfaitement. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Compléments sur les mémoires de masse | prevText=Compléments sur les mémoires de masse | next=Le préchargement | nextText=Le préchargement }} </noinclude> 97focbdsj7585kbn34ps3kwss6gq270 765124 765123 2026-04-26T15:41:50Z Mewtow 31375 /* L'exemple des processeurs 386 et du contrôleur de cache 82385 */ 765124 wikitext text/x-wiki Le cache est une mémoire intercalée entre la mémoire et un processeur, plus rarement à l'intérieur d'un périphérique. Il est souvent fabriquée avec de la mémoire SRAM, parfois avec de l'eDRAM. Sans lui, on se croirait à l'âge de pierre tellement nos PC seraient lents ! En effet, la mémoire est très lente comparée au processeur. Le temps mis pour accéder à la mémoire est du temps durant lequel le processeur n'exécute pas d'instruction (sauf cas particuliers impliquant un pipeline). Pour diminuer ce temps d'attente, il a été décidé d'intercaler une mémoire petite mais rapide, entre le processeur et la mémoire. Ainsi, le processeur accède à un cache très rapide plutôt qu'à une RAM beaucoup plus lente. ==L'accès au cache== Le cache contient une copie de certaines données présentes en RAM. La copie présente dans le cache est accessible bien plus rapidement que celle en RAM, vu que le cache est plus rapide. Mais seule une petite partie de ces données sont copiées dans le cache, les autres données devant être lues ou écrites dans la RAM. Toujours est-il que le cache contient une copie des dernières données accédées par le processeur. Une donnée est copiée dans la mémoire cache quand elle est lue ou écrite par le processeur. Le processeur conserve une copie de la donnée dans le cache après son premier accès. Les lectures/écritures suivantes se feront alors directement dans le cache. Évidemment, au fur et à mesure des accès, certaines données anciennes sont éliminées du cache pour faire de la place aux nouveaux entrants, comme nous le verrons plus tard. [[File:Principe d'une mémoire cache.gif|centre|vignette|upright=2|Principe d'une mémoire cache.]] La mémoire cache est invisible pour le programmeur, qui ne peut pas déceler celles-ci dans l'assembleur. Les accès mémoire se font de la même manière avec ou sans le cache. La raison à cela est que le cache intercepte les accès mémoire et y répond s'il en a la capacité. Par exemple, si le cache intercepte une lecture à une adresse et que le contenu de cette adresse est dans le cache, le cache va outrepasser la mémoire RAM et la donnée sera envoyée par le cache au lieu d'être lue en RAM. par contre, si un accès se fait à une adresse pour laquelle le cache n'a pas la donnée, alors l'accès mémoire sera effectué par la RAM de la même manière que si le cache n'était pas là. [[File:Accès au cache.png|centre|vignette|upright=2|Accès au cache]] ===Les succès et défauts de caches=== Tout accès mémoire est intercepté par le cache, qui vérifie si la donnée demandée est présente ou non dans le cache. Si la donnée voulue est présente dans le cache, on a un '''succès de cache''' (''cache hit'') et on accède à la donnée depuis le cache. Sinon, c'est un '''défaut de cache''' (''cache miss'') et on est obligé d’accéder à la RAM. Les défauts de cache peuvent avoir plusieurs origines. Tout ce qu'il faut savoir est que lorsque le processeur accède à une donnée ou une instruction pour la première fois, il la place dans la mémoire cache car elle a de bonnes chances d'être réutilisée prochainement. La raison à cela est qu'un programme a tendance à réutiliser les instructions et données qui ont été accédées dans le passé : c'est le ''principe de localité temporelle''. Bien évidement, cela dépend du programme, de la façon dont celui-ci est programmé et accède à ses données et du traitement qu'il fait, mais c'est souvent vrai en général. La première cause des défauts de cache est liée à la taille du cache. À force de charger des données/instructions dans le cache, le cache fini par être trop petit pour conserver les anciennes données. Le cache doit bien finir par faire de la place en supprimant les anciennes données, qui ont peu de chances d'être réutilisées. Ces anciennes données éliminées du cache peuvent cependant être accédées plus tard. Tout prochain accès à cette donnée mènera à un cache miss. C'est ce qu'on appelle un ''Capacity Cache Miss'', ou encore '''défaut de capacité'''. Les seules solutions pour éviter cela consistent à augmenter la taille du cache ou à optimiser le programme exécuté (voir plus bas). Une autre raison pour un défaut est donc la suivante. Lorsqu'on exécute à une instruction ou qu'on accède à donnée pour la première fois, celle-ci n'a pas encore été chargée dans le cache. Le défaut de cache est inévitable : ce genre de cache miss s'appelle un ''Cold Miss'', ou encore un '''défaut à froid'''. De tels défauts sont presque impossibles à éliminer, sauf à utiliser des techniques de préchargement qui chargent à l'avance des données potentiellement utiles. Ces méthodes de préchargement se basent sur le principe de localité spatiale, à savoir le fait que les programmes ont tendance à accéder à des données proches en mémoire. Pour donner un exemple, les instructions d'un programme sont placées en mémoire dans l’ordre dans lequel on les exécute : la prochaine instruction à exécuter est souvent placée juste après l'instruction en cours (sauf avec les branchements). Quand on accède à une donnée ou une instruction, le cache peut précharger les données adjacentes pour en profiter. Nous parlerons de ces techniques de préchargement dans un chapitre dédié, vers la fin du cours. ===Le fonctionnement du cache, vu du processeur=== Vu du processeur, le cache prend en entrée toutes les informations nécessaires pour effectuer un accès mémoire : des signaux de commande, une adresse et la donnée à écrire si besoin. Tout cela est passé en entrée du cache, celui-ci répondant aux accès mémoire via divers bits de contrôles, que le processeur peut lire à souhait. Le cache fournit aussi la donnée à lire, pour les lectures, sur une sortie, connectée directement au bus mémoire/processeur. Globalement, le cache a une capacité limitée, mais il prend en entrée des adresses complètes. Par exemple, sur un processeur 64 bits, le cache prend en entrée des adresses de 64 bits (sauf si optimisations), même si le cache en question ne fait que quelques mébioctets. Les caches sont souvent des mémoires multiports, surtout sur les processeurs récents. Les caches simple port sont rares, mêmes s'ils existent et ont existé par le passé. les caches double port sont eux plus fréquents, et ont généralement un port d'écriture séparé du port de lecture. Mais les caches récents ont plusieurs ports de lecture/écriture et sont capables de gérer plusieurs accès mémoire simultanés. Les données présentes dans le cache sont (pré)chargées depuis la mémoire, ce qui fait que toute donnée dans le cache est la copie d'une donnée en mémoire RAM. Le cache doit faire la correspondance entre une donnée du cache et l'adresse mémoire correspondante. Du point de vue du fonctionnement, on peut voir le cache comme une sorte de table de correspondance, qui mémorise des données, chacune étant associée à son adresse mémoire. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Cela vaut du point de vue du processeur, le fonctionnement interne du cache étant quelque peu différent selon le cache. Il existe des caches dont le fonctionnement interne est bien celui d'une table de correspondance matérielle, d'autres qui sont beaucoup plus optimisés. [[File:Fonctionnement d'une mémoire associative à correspondance.png|centre|vignette|upright=2|Fonctionnement simplifié d'une mémoire cache : les adresses sont dans la colonne de gauche, les données sont dans la colonne de droite. On voit qu'on envoie l'adresse au cache, que celui-ci répond en renvoyant la donnée associée.]] ==La performance des mémoires caches== L'analyse de la performance des mémoires caches est plus riche pour celle des autres mémoires. Sa performance dépend de beaucoup de paramètres, mais on peut cependant citer les principaux. Les deux premiers sont tout bonnement sa latence et son débit, comme pour n'importe quelle autre mémoire. La latence est plus importante que son débit, car le processeur est généralement plus rapide que le cache et qu'il n'aime pas attendre. Mais le critère le plus important pour un cache est sa capacité à empêcher des accès mémoire, son efficacité. Plus les accès mémoire sont servis par le cache au lieu de la RAM, meilleures seront les performances. Pour résumer, la performance d'un cache est surtout caractérisée par deux métriques : le taux de défaut, qui correspond à l’efficacité du cache, et la latence du cache. ===Le taux de succès/défaut=== Le '''taux de succès''' (hit ratio) est un premier indicateur des performances du cache, mais un indicateur assez imparfait. C'est le pourcentage d'accès mémoire qui ne déclenchent pas de défaut de cache. Plus il est élevé, plus le processeur accède au cache à la place de la RAM et plus le cache est efficace. Certains chercheurs préfèrent utiliser le '''taux de défauts''', à savoir le pourcentage d'accès mémoire qui entraînent un défaut de cache. Plus il est bas, meilleures sont les performances. Le taux de défaut est relié au taux de succès par l'équation <math>T_\text{succes} = 1 - T_\text{defaut}</math>. Par définition, il est égal à : : <math>\text{Taux de défauts de cache} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d’accès mémoires}}</math> Plutôt que de comparer le nombre de défauts/succès de cache au nombre d'accès mémoire, il est aussi possible de diviser le nombre de défauts par le nombre total d'instructions. On obtient alors le '''taux de défauts/succès par instruction''', une autre métrique utile. Par définition, elle est égale à : : <math>\text{Taux de défauts par instruction} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d'instructions}} = \text{Taux de défauts de cache} \times \frac{\text{Nombre d’accès mémoires}}{\text{Nombre d'instructions}}</math> Si certains défauts de cache sont inévitables quel que soit le cache, comme les défauts à froids, mentionnés plus haut, d'autres défauts peuvent être évités en augmentant la capacité du cache. C'est le cas des défauts de capacité qui sont causés par un accès à une donnée qui a été éliminée du cache faute de place. Plus le cache est gros, moins il a de chances d'être rempli, moins il doit rapatrier de données, plus son taux de succès augmente. Mais nous reviendrons sur le lien entre taille du cache et taux de défaut plus bas. Le taux de succès ne dépend pas que du cache, mais aussi de la conception des programmes exécutés. Une bonne utilisation du cache (ainsi que de la mémoire virtuelle) repose sur le programmeur qui doit prendre en compte les principes de localités dès la conception de ses programmes. Par exemple, un programmeur peut parfaitement tenir compte du cache au niveau de son algorithme : on peut citer l'existence des algorithmes ''cache oblivious'', qui sont conçus pour être optimaux quelle que soit la taille du cache. Le programmeur peut aussi choisir ses structures de données de manière à améliorer la localité. Par exemple, un tableau est une structure de donnée respectant le principe de localité spatiale, tandis qu'une liste chaînée ou un arbre n'en sont pas (bien qu'on puisse les implémenter de façon à limiter la casse). D'autres optimisations sont parfois possibles : par exemple, le sens de parcours d'un tableau multidimensionnel peut faire une grosse différence. Cela permet des gains très intéressants pouvant se mesurer avec des nombres à deux ou trois chiffres. Je vous recommande, si vous êtes programmeur, de vous renseigner le plus possible sur les optimisations de code ou algorithmiques qui concernent le cache : il vous suffira de chercher sur Google. Il y a une citation qui résume bien cela, prononcée par un certain Terje Mathisen. Si vous ne le connaissez pas, cet homme est un vieux programmeur (du temps durant lequel on codait encore en assembleur), grand gourou de l’optimisation, qui a notamment travaillé sur le moteur de Quake 3 Arena. {{BlocCitation|Almost all programming can be viewed as an exercise in caching.|auteur=Terje Mathisen}} ===La latence moyenne d'un cache=== Le temps mis pour lire ou écrire une donnée varie en présence d'un cache. Certaines lectures/écritures vont atterrir directement dans le cache (succès) tandis que d'autres devront aller chercher leur contenu en mémoire RAM (défaut de cache). Dans tous les cas, qu'il y ait défaut ou non, le cache sera consulté et mettra un certain temps à répondre, égal au temps de latence du cache. Tous les accès mémoires auront donc une durée au moins égale au temps de latence du cache, qui sera notée <math>T_c</math>. En cas de succès, le cache aura effectué la lecture ou l'écriture, et aucune action supplémentaire n'est requise. Ce qui n'est pas le cas en cas de défaut : le processeur devra aller lire/écrire la donnée en RAM, ce qui prend un temps supplémentaire égal au temps de latence de la mémoire RAM. Un défaut ajoute donc un temps, une pénalité, à l'accès mémoire. Dans ce qui suivra, le temps d'accès à la RAM sera noté <math>T_m</math>. Fort de ces informations, nous pouvons calculer le temps de latence moyen d'un accès mémoire, qui est la somme du temps d'accès au cache (pour tous les accès mémoire), multiplié par le temps lié aux défauts. On a alors : : <math>T = T_c + \text{Taux de défaut} \times T_m</math> On voit que plus le taux de succès est élevé, plus le temps de latence moyen sera bas, et inversement. Ce qui explique l'influence du taux de succès sur les performances du cache, influence assez importante sur les processeurs actuels. De nos jours, le temps que passe le processeur dans les défauts de cache devient de plus en plus un problème au fil du temps, et gérer correctement le cache est une nécessité, particulièrement sur les processeurs multi-cœurs. Il faut dire que la différence de vitesse entre processeur et mémoire est tellement importante que les défauts de cache sont très lents : alors qu'un succès de cache va prendre entre 1 et 5 cycles d'horloge, un cache miss fera plus dans les 400-1000 cycles d'horloge. Tout ce temps sera du temps de perdu que le processeur aura du mal à mitiger. Autant dire que réduire les défauts de cache est beaucoup plus efficace que d'optimiser les calculs effectués par le processeur (erreur courante chez de nombreux programmeurs, notamment débutants). ===L'impact de la taille du cache sur le taux de défaut et la latence=== Il y a un lien entre taille du cache, taux de défaut, débit binaire et latence moyenne. Globalement, plus un cache est gros, plus il est lent. Simple application de la notion de hiérarchie mémoire vue il y a quelques chapitres. Les raisons à cela sont nombreuses, mais nous ne pouvons pas les aborder ici, car il faudrait que nous sachions comment fonctionne un cache et ce qu'il y a à l'intérieur, ce qui sera vu dans la suite du chapitre. Toujours est-il que la latence moyenne d'un cache assez gros est assez importante. De même, le débit binaire d'un cache diminue avec sa taille, mais dans une moindre mesure. Les petits caches ont donc un gros débit binaire et une faible latence, alors que c'est l'inverse pour les gros caches. Une grande capacité de cache améliore le taux de succès, mais cela se fait au détriment de son temps de latence et de son débit, ce qui fait qu'il y a un compromis assez difficile à trouver entre taille du cache, latence et débit. Il peut arriver qu'augmenter la taille du cache augmente son temps d'accès au point d’entraîner une baisse de performance. Par exemple, les processeurs Nehalem d'Intel ont vus leurs performances dans certains jeux vidéos baisser de 2 à 3 %, malgré de nombreuses améliorations architecturales, parce que la latence du cache L1 avait augmentée de 2 cycles d'horloge. Pour avoir une petite idée du compromis à faire, regardons la relation entre taille du cache et taux de défaut. Il existe une relation approximative entre ces deux variables, appelée la '''loi de puissance des défauts de cache'''. Elle donne le nombre total de défaut de cache en fonction de la taille du cache et de deux autres paramètres. Voici cette loi : : <math>\text{Taux de défauts de cache} \approx K \times \text{Taille du cache}^{- \alpha }</math>, avec <math>K</math> et <math>\alpha</math> deux coefficients qui dépendent du programme exécuté. Le coefficient <math>\alpha</math> est généralement compris entre 0.3 et 0.7, guère plus, et varie suivant le programme exécuté. Précisons que cette loi ne marche que si le cache est assez petit par rapport aux données à utiliser. Pour un cache assez gros et des données très petites, la relation précédente est mise en défaut. Pour s'en rendre compte, il suffit d'étudier le cas extrême où toutes les données nécessaires tiennent dans le cache. Dans ce cas, il n'y a qu'un nombre fixe de défauts de cache : autant qu'il faut charger de données dans le cache. Le nombre de défauts de cache observé dans cette situation n'est autre que le coefficient <math>K</math> de la situation précédente, mais il n'y a aucune dépendance entre taux de défaut et taille du cache. L'origine de cette relation s'explique quand on regarde combien de fois chaque donnée est réutilisée lors de l’exécution d'un programme. La plupart des données finissent par être ré-accédées à un moment ou un autre et il se passe un certain temps entre deux accès à une même donnée. Sur la plupart des programmes, les observations montrent que beaucoup de réutilisations de données se font après un temps très court et qu'inversement, peu de ré-accès se font après un temps inter-accès long. Si on compte le nombre de réutilisation qui ont un temps inter-accès bien précis, on retrouve une loi de puissance identique à celle vue précédemment : : <math>\text{Nombre de réaccès avec un temps inter-accès égal à t} \approx K \times t^{- \beta}</math>, avec t le temps moyen entre deux réutilisations. Le coefficient <math>\beta</math> est ici compris entre 1.7 et 1.3. De manière générale, les coefficients <math>\alpha</math> et <math>\beta</math> sont reliés par la relation <math>\alpha = 1 - \beta</math>, ce qui montre qu'il y a un lien entre les deux relations. Précisons cependant que la loi de puissance précédente ne vaut pas pour tous les programmes informatiques, mais seulement pour la plupart d’entre eux. Il n'est pas rare de trouver quelques programmes pour lesquels les accès aux données sont relativement prédictibles et où une bonne optimisation du code fait que la loi de puissance précédente n'est pas valide. La loi de puissance des défauts de cache peut se démontrer à partir de la relation précédente, sous certaines hypothèses. Si un suppose que le cache est assez petit par rapport aux données, alors les deux relations sont équivalentes. L'idée qui se cache derrière la démonstration est que si le temps entre deux accès à une donnée est trop long, alors la donnée accédée aura plus de chance d'être rapatriée en RAM, ce qui cause un défaut de cache. La chance de rapatriement dépend de la taille du cache, un cache plus gros peut conserver plus de données et a donc un temps avant rapatriement plus long. ==Les lignes de cache et leurs tags== Du point de vue du processeur, les lectures et écritures se font mot mémoire par mot mémoire. Un processeur avec des entiers de 64 bits recoit des données de 64 bits de la part du cache, et y écrit des mots de 64 bits. Mais quand on regarde comment sont stockées les données à l'intérieur du cache, les choses sont différentes. ===Les lignes de cache=== Les données sont mémorisées dans le cache par blocs de plusieurs bytes, d'environ 64 à 256 octets chacun, qui portent le nom de '''lignes de cache'''. Les lignes de cache sont l'unité de stockage que l'on trouve à l'intérieur du cache, mais elles servent aussi d'unité de transaction avec la mémoire RAM. Sur les caches actuels, on transfère les données entre le cache et la RAM ligne de cache par ligne de cache, dans la limite de la taille du bus mémoire. Mais d'autres caches plus anciens permettaient de faire des transferts plus fins. C’est-à-dire qu'on pouvait mettre à jour quelques octets dans une ligne de cache sans avoir à la recopier intégralement depuis ou dans la mémoire RAM. En théorie, on pourrait imaginer des caches où les données sont stockées différemment, où l'unité serait le mot mémoire, par exemple. Par exemple, sur un processeur 64 bits, on aurait une ligne de cache de 64 bits. Cela aurait l'avantage de la simplicité : les transferts entre le processeur et la mémoire serait de même taille, l'intérieur du cache ressemblerait à son interface montrée au processeur. Mais cela aurait quelques défauts qui sont compensés par l'organisation en lignes de cache de grande taille. Le premier avantage des lignes de cache est lié à la localité spatiale, la tendance qu'on les programmes à accéder à des données proches les unes des autres. Des accès mémoires consécutifs ont tendance à se faire à des adresses proches, qui ont de bonnes chances d'être dans la même ligne de cache. Et des accès consécutifs à une même ligne de cache sont plus rapides que des accès à deux lignes distinctes. Une autre raison est tout simplement que cela simplifie considérablement la circuiterie du cache. Pour une capacité identique, il vaut mieux avoir peu de lignes de cache assez grosses, que beaucoup de petites lignes de cache. La raison est que les circuits du cache, comme le décodeur, l'encodeur et autres, ont moins de sorties et sont donc plus simples. ===L'alignement des lignes de cache=== Les lignes de cache sont des blocs de plusieurs dizaines à centaines de bytes, dont la taille est presque toujours une puissance de deux. De plus, les lignes de cache sont alignées en mémoire. Nous avions déjà abordé la notion d'alignement mémoire dans un chapitre précédent, mais le concept d'alignement des lignes de cache est quelque peu différent. Quand nous avions parlé d'alignement auparavant, il s'agissait de l'alignement des données manipulées par le processeur, qui faisait partie du jeu d'instruction du processeur. Ici, nous parlons d'un alignement totalement différent, invisible pour le programmeur, sans lien avec le jeu d’instruction. Voyons de quoi il retourne. Concrètement, cela veut dire que du point de vue du cache, la RAM est découpée en blocs qui font la même taille qu'une ligne de cache, aux positions prédéterminées, sans recouvrement entre les blocs. Par exemple, pour un cache dont les lignes de cache font 256 octets, le premier bloc est à l'adresse 0, le second est 256 octets plus loin, c'est à dire à l'adresse 256, le troisième à l'adresse 512, la quatrième à l'adresse 768, etc. Une ligne de cache de 256 octets contiendra une donnée provenant d'un bloc de RAM de 256 octets, dont l'adresse est systématiquement un multiple de 256. Il n'est pas possible qu'une ligne de cache contienne un bloc de 256 octets dont l'adresse du premier octet serait l'adresse 64, ou l'adresse 32, par exemple. En clair, les adresses de ces blocs sont des multiples de la taille de la ligne de cache, de la taille des blocs. Cela rappelle les contraintes d'alignement vues dans le chapitre "Le modèle mémoire : alignement et boutisme", mais appliquées aux lignes de cache. L'alignement des lignes de cache a des conséquences pratiques pour la conception des caches. Notons qu'il est en théorie possible d'avoir des caches dont les lignes de cache ne sont pas alignées, mais cela poserait des problèmes majeurs. Il serait en effet possible qu'une donnée soit présente dans deux lignes de cache à la fois. Par exemple, prenons le cas où une ligne de cache de 256 commence à l'adresse 64 et une autre ligne de cache commence à l'adresse 0. L'adresse 128 serait dans les deux lignes de cache ! Et cela poserait des problèmes lors des lectures, mais encore plus lors des écritures. C'est pour éviter ce genre de problèmes que les lignes de cache sont alignées avec la mémoire RAM dans tous les caches existants. L'alignement des lignes de cache est une chose que les programmeurs doivent parfois prendre en compte quand ils écrivent du code ultra-optimisé, destiné à des programmes demandant des performances extrêmes. Il arrive que les contraintes d'alignement posent des problèmes. Nous avions vu dans le chapitre sur le boutisme et l'alignement qu'il valait mieux gérer l'alignement des variables des structures de données, pour éviter les accès non-alignés avec le bus mémoire. La même chose est possible, mais pour l'alignement avec des lignes de cache. Typiquement, l'idéal est que, pour une structure de donnée, on puisse en mettre un nombre entier dans une ligne de cache. Ou alors, si la structure est vraiment grande, que celle-ci occupe un nombre entier de lignes de cache. Si ce n'est pas le cas, il y a un risque d'accès non-alignés, c'est à dire qu'une structure se retrouve à cheval sur deux lignes de cache, avec les défauts que cela implique. ===Le tag d'une ligne de cache=== Plus haut, nous avions dit que le cache mémorise, pour chaque ligne de cache, l'adresse RAM associée. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Mais du fait de l'organisation du cache en lignes de cache de grande taille, qui sont de plus alignées en mémoire, il faut nuancer cette affirmation. Le cache ne mémorise pas la totalité de l'adresse, ce qui serait inutile. L'alignement des lignes de cache en RAM fait que les bits de poids faible de l'adresse ne sont pas à prendre en compte pour l'association adresse-ligne de cache. Dans ces conditions, on mémorise seulement la partie utile de l'adresse mémoire correspondante, qui forme ce qu'on appelle le '''tag'''. Le reste de l'adresse indique quelle est la position de la donnée dans la ligne de cache. Par exemple, prenons le cas où le processeur gère des nombres entiers de 64 bits (8 octets) et des lignes de cache de 128 octets : chaque ligne de cache contient donc 16 entiers. Si le processeur veut lire ou écrire un entier bien précis, il doit préciser sa place dans la ligne de cache. Et ce sont les bits de l'adresse mémoire non-inclus dans le cache qui permettent de faire ça. En clair, une adresse mémoire à lire/écrire est interprété par le cache comme la concaténation d'un tag et de la position de la donnée dans la ligne de cache correspondante. [[File:Adressage d'un cache totalement associatif.png|centre|vignette|upright=2|Adressage d'un cache totalement associatif]] Le cache est donc une grande table de correspondance entre tags et lignes de cache. Lors d'un accès mémoire, le cache extrait le tag de l'adresse à lire ou écrire, et le compare avec les tags de chaque ligne de cache. Si une ligne contient ce tag, alors c'est que cette ligne correspond à l'adresse, et c'est un défaut de cache sinon. Lors d'un succès de cache, la ligne de cache est lue depuis le cache et envoyée à un multiplexeur qui sélectionne la donnée à lire dans la ligne de cache. Le fonctionnement est similaire pour une écriture : la donnée à écrire passe dans un démultiplexeur, qui envoie la donnée au bon endroit dans la ligne de cache sélectionnée. [[File:Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.png|centre|vignette|upright=2|Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.]] ===Le contenu d'une ligne de cache=== Dans ce qui va suivre, nous allons considérer que chaque ligne de cache mémorise son tag, les données de la ligne de cache proprement dit, et quelques bits de contrôle annexes qui varient suivant le cache considéré. [[File:Tag d'une ligne de cache.png|centre|vignette|upright=2|Tag d'une ligne de cache.]] Les caches modernes incluent de nombreux bits de contrôle, mais deux d'entre eux sont communs à presque tous les caches modernes : le bit ''Dirty'' et le bit ''Valid''. Le '''bit ''Valid''''' indique si la ligne de cache contient des données valides ou non. Si le bit ''Valid'' est à 0, la ligne de cache est en état valide, à savoir qu'elle contient des données et n'est pas vide. Par contre, si ce bit est à 1, la ligne de cache est invalide et son contenu ne peut pas être lu ou écrit. L'utilité de ce bit est qu'il permet d'effacer une ligne de cache très rapidement : il suffit de mettre ce bit à 0. Il existe des situations où le cache doit être effacé, on dit alors qu'il est invalidé. Une section de ce chapitre sera dédié à l'invalidation du cache. Le '''bit ''Dirty''''' indique qu'une ligne de cache a été modifiée. Par modifiée, on veut dire que le processeur a écrit dedans, qu'il a modifié la ligne de cache. Mais attention : si la donnée a été modifiée dans le cache, la modification n'est pas forcément propagée en mémoire RAM. Le bit ''dirty'' indique si c'est le cas, si l'écriture a été propagée en mémoire RAM. Il précise que la ligne de cache contient des données modifiées, alors que la RAM a des données initiales non-modifiées. Une ligne de cache avec un bit ''dirty'' à 1 est dite ''dirty'', par métonymie. Nous verrons cela en détail dans la section sur les caches ''write-back'' et ''write-through''. Les caches modernes ajoutent des '''bits de détection/correction d'erreur''' dans les bits de contrôle. Pour rappel, les codes de détection/correction d'erreur permettent de se prémunir contre des erreurs matérielles, qui corrompent les données stockées dans une mémoire, ici une mémoire cache. Ils ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Nous reviendrons dessus dans une section ultérieur de ce chapitre. Sur certains caches assez anciens, on pouvait transférer les lignes de caches morceaux par morceaux. Ces caches avaient des lignes de cache divisées en sous-secteurs, ces sous-secteurs étant des morceaux de ligne de cache qu'on pouvait charger indépendamment les uns des autres (mais qui sont consécutifs en RAM). Chaque secteur avait ses propres bits de contrôle, mais le tag était commun à tous les secteurs. [[File:Cache à secteurs.png|centre|vignette|upright=2.5|Cache à secteurs.]] : Dans ce qui va suivre, le terme "ligne de cache" désignera soit un bloc de données copiées depuis la RAM d'une taille de 64/128/256/... octets, soit la concaténation de ces données avec le tag et des bits de contrôle. Les deux définitions ne sont pas équivalentes, mais l'usage a entériné cet abus de langage. Et il faut avouer que cela rend les explications du chapitre plus simples. ==Les instructions de contrôle du cache== Plus haut, nous avions dit que le cache est totalement transparent du point de vue du programmeur. Le cache contient des copies de données en RAM, le programmeur n'a rien à faire pour utiliser le cache correctement. Mais la réalité est que pour des raisons diverses, des processeurs incorporent des '''instructions de contrôle du cache'''. Il s'agit d’instructions qui agissent sur le contenu du cache. Elles existent pour des raisons diverses qu'on détaillera plus bas, mais il s'agit globalement d'une question de performances ou de nécessité pour le système d'exploitation. ===Les instructions de préchargement=== La première instruction de contrôle du cache est une '''instruction de préchargement''', qui demande à charger un bloc de données dans le cache. Elle prend en opérande une adresse mémoire, et le contenu de cette adresse est chargé dans une ligne de cache. Bien sûr, des contraintes d'alignement sont à prendre en compte : on charge un bloc de la même taille qu'une ligne de cache, aligné en mémoire sur la taille du bloc, qui contient l'adresse. L'instruction de préchargement n'est utile que si l'instruction est exécutée bien avant que la donnée ne soit utilisée/lue/écrite. Cela permet de charger une donnée dans le cache à l'avance, d'où le nom de préchargement donné à cette technique. Mais les processeurs modernes gérent des techniques de préchargement automatique, qui ne requièrent pas d'instructions de préchargement. Le préchargement automatique et les instructions de préchargement sont deux solutions complémentaires, mais qui peuvent se marcher sur les pieds. Nous en reparlerons dans le prochain chapitre, qui sera dédié au préchargement automatique. Il faut noter que les instructions de préchargement peuvent être ignorées par le processeur. Sous certaines conditions, le processeur peut décider que l'instruction de préchargement ne sera pas exécutée. Par exemple, il ne va pas précharger une donnée déjà présente dans le cache. Ou encore, si le bus mémoire est occupé, il ne va pas exécuter le préchargement, par manque de ressources matérielles. ===Les instructions d'invalidation et de ''flush''=== Les instructions ''flush'' regroupent deux types d'instructions qui sont souvent utilisées en même temps. Il s'agit des instructions d'invalidation et de nettoyage (''clean''). Les deux termes proviennent de la terminologie ARM, il n'y a pas de terminologie standardisé pour les noms de ces instructions. Dans les grandes lignes, elles permettent de vider le cache, à savoir de rapatrier son contenu en RAM et de réinitialiser le cache à zéro. Elles sont utilisées par le système d'exploitation lors des commutations de contexte, à savoir quand on passe d'un programme à un autre. Elles sont aussi utilisées lors des appels systèmes et routines d'interruption/exception. L'idée est de vider le cache avant d'exécuter un nouveau programme ou une nouvelle routine. Le nouveau programme aura accès à un cache tout propre, les données de l'ancien programme auront été retirée du cache. Les '''instructions ''clean''''' recopient le contenu de la ligne de cache en RAM. Elles forcent la recopie immédiatement de la ligne de cache en mémoire RAM. Pour faire leur travail, elle vérifient si la ligne de cache a été modifiée, avant de la recopier en RAM. Et pour cela, ils vérifient le bit de contrôle ''dirty'', qui est mis à 1 après une première écriture. Si ce bit est à 0, alors pas besoin de recopier la ligne de cache : elle n'a pas été modifiée, la RAM a déjà la bonne copie. Mais s'il est à 1, le cache et la RAM n'ont pas le même contenu, la recopie s'exécute. Les '''instructions d'invalidation''' permettent d'invalider une ligne de cache, à savoir d'effacer son contenu. Nous verrons à quoi servent ces instructions dans la section sur les changement de processus. Invalider une ligne de cache est une opération optimisée : le cache n'est en réalité pas réellement effacé. À la place, le bit ''Valid'' de chaque ligne de cache est juste mis à 0. Il faut noter que l'invalidation efface les lignes de cache sans se préoccuper de leur contenu. Elle se moque qu'une ligne de cache contienne une donnée modifiée, ''dirty'' ou quoique ce soit : la ligne de cache est effacée, point. Il est possible d'invalider une ligne de cache en fournissant une adresse mémoire, mais il est aussi possible d'invalider le cache tout entier. Le choix entre les deux dépend du mode d'adressage de l'instruction d'invalidation. Parfois, il existe une instruction séparée pour invalider tout le cache, et une autre pour invalider une ligne de cache bien précise. Des instructions séparées sont parfois disponibles pour invalider les caches de données et d'instructions, parfois aussi la TLB (un cache qu'on verra dans quelques chapitres). Il est possible de n'invalider que le cache L1, voire le cache L2. Il faut noter que l'invalidation efface tout le cache, mais ne se préoccupe pas de vérifier si les données ont été modifiées dans le cache. Pour certains caches, comme le cache d'instruction, ce n'est pas un problème, vu qu'il est en "lecture seule". Mais pour les caches de données, les données modifiées sont perdues en cas d'invalidation. Heureusement, il existe des instructions d'invalidation qui fusionnent une instruction ''clean'' et une instruction d'invalidation. Il s'agit d''''instructions d'invalidation spéciales'''. ===Les instructions d'optimisation : instructions non-temporelles et écritures optimisées=== Les '''instructions mémoire non-temporelles''' contournent complètement le cache. Par exemple, une lecture peut lire une donnée, mais celle-ci ne sera pas chargée dans le cache, elle passe directement de la RAM vers les registres. Une section entière de ce chapitre sera dédiée au contournement du cache, à savoir aux situations où les accès mémoire doivent passer directement du processeur à la RAM sans passer par le cache. D'autres instructions assez rares incorporent des indications pour le cache. Par exemple, l'instruction ''load last'' des processeurs POWER PC implique que la donnée ne sera utilisée qu'une seule fois. Elle est donc chargée dans le cache, mais la ligne de cache est configurée de manière à être remplacée très rapidement, typiquement avec une valeur de LRU/LFU adéquate. La donnée est bien chargée dans le cache, au cas où elle doive être relue suite à une mauvaise prédiction de branchement ou autre, chose qu'une lecture non-temporelle (qui contourne le cache) ne fait pas. Des indications de ce type sont appelées des '''''cache hint'''''. L''''instruction ''flush''''' permet de préciser qu'une ligne de cache contient une donnée inutile, qui ne sera pas réutilisée par le programme. Pas besoin de la conserver dans le cache, elle peut laisser sa place à des données plus utiles. Or, sans indication, les algorithmes de remplacement d'une ligne de cache risquent de conserver cette donnée trop longtemps, ce qui entraine une certaine pollution du cache par des données inutiles. Une autre instruction est elle beaucoup plus importante : celle de '''pré-allocation sur écriture'''. Elle sert dans le cas où une ligne de cache est complétement écrite. Par exemple, imaginons qu'on veuille écrire dans une portion de mémoire. Si celle-ci n'est pas dans le cache, le processeur va charger une ligne de cache complète depuis la RAM, écrire dans la ligne de cache, puis recopier la ligne de cache modifiée en mémoire RAM. Une écriture en RAM demande donc de faire une lecture et une écriture. Mais les instructions de pré-allocation sur écriture permettent de prévenir qu'une ligne de cache sera intégralement écrite, et qu'il n'y a donc pas besoin de lire celle-ci depuis la RAM. Notons que l'instruction d'écriture qui suit n'est pas une écriture non-temporelle, vu que les données sont écrites dans la ligne de cache, qui est ensuite envoyée en mémoire RAM dès que nécessaire. De plus, les données écrites peuvent ensuite être relue depuis le cache si nécessaire. Enfin, certains processeurs MIPS incorporent une instruction pour modifier le tag d'une ligne de cache. Elles servent à optimiser les copies mémoire, à savoir quand on copie un bloc de données d'un endroit à un autre. L'idée est de charger le bloc de données dans le cache avec une instruction LOAD/PREFETCH, de modifier le tag pour qu'il pointe vers l'adresse à écrire, et de laisser faire le cache pour que l'écriture se fasse en RAM. Mais les contraintes pour utiliser cette instruction sont assez drastiques : les données doivent être alignées sur la taille d'une ligne de cache, le bloc de départ et d'arrivée (l'original versus la copie) ne doivent pas se recouvrir, etc. ==L'associativité des caches et leur adressage implicite== Lorsqu'on souhaite accéder au cache, il faut trouver quelle est la ligne de cache dont le tag correspond à l'adresse demandée. On peut classifier les caches selon leur stratégie de recherche de la ligne correspondante en trois types de caches : totalement associatifs, directement adressés (''direct mapped'') et associatifs par voie. ===Les caches totalement associatifs=== Avec les caches totalement associatifs, toute donnée chargée depuis la mémoire peut être placée dans n'importe quelle ligne de cache, sans aucune restriction. Ces caches ont un taux de succès très élevé, quand on les compare aux autres caches. [[File:Cache totalement associatif.png|centre|vignette|upright=2|Cache totalement associatif.]] Concevoir un cache totalement associatif peut se faire de deux grandes manières différentes. La première consiste tout simplement à combiner une mémoire associative avec une mémoire RAM, en ajoutant éventuellement quelques circuits annexes. La mémoire associative mémorise les tags, alors que la mémoire RAM mémorise les données de la ligne de cache, éventuellement avec quelques bits de contrôle. La ligne de cache est stockée à une adresse A dans la mémoire RAM et son tag est stocké à la même adresse, mais dans la mémoire CAM. Ce faisant, quand on envoie le tag à la mémoire CAM, elle renvoie l'adresse de la ligne de cache dans la mémoire RAM. Cette adresse est alors envoyée directement sur le bus d'adresse de la RAM, et la lecture est effectuée automatiquement. Il faut ajouter quelques circuits annexes pour garantir que les écritures se passent correctement dans les deux mémoires, mais rien de bien terrible. [[File:Cache fabriqué avec une mémoire associative et une RAM.png|centre|vignette|upright=3|Cache fabriqué avec une mémoire associative et une RAM]] Il est cependant possible d'optimiser un tel cache, en fusionnant la mémoire CAM et la mémoire RAM, afin d'éliminer des circuits redondants. Pour comprendre pourquoi, rappelons que les mémoires CAM sont composées d'un plan mémoire, d'un paquet de comparateurs et d'un encodeur. Quant à la mémoire RAM, elle est composée d'un décodeur connecté au plan mémoire. En mettant une CAM suivie d'une RAM, on a un encodeur dont l'entrée est envoyée à un décodeur. [[File:Cache totalement associatif naif.png|centre|vignette|upright=3|Cache totalement associatif naif]] Or, le décodeur réalise l'opération inverse de l'encodeur, ce qui fait que mettre les deux composants à la suite ne sert à rien. On peut donc retirer l'encodeur et le décodeur, et envoyer directement les résultats des comparateurs sur les entrées de commande du plan mémoire de la RAM. [[File:Cache totalement associatif optimisé.png|centre|vignette|upright=2|Cache totalement associatif optimisé]] Avec cette méthode, les circuits du cache ressemblent à ce qui illustré ci-dessous. Le tag est envoyé à chaque ligne de cache. Le tag envoyé est alors comparé avec le Tag contenu dans chaque ligne de cache, comme c'est le cas sur les mémoires associatives. Si une ligne de cache matche avec le tag envoyé en entrée, la ligne pour laquelle il y a eu une égalité est alors connectée sur les lignes de bit (''bitlines''). Cela est réalisé par un circuit commandé par le comparateur de la ligne de cache. Il ne reste plus qu'à sélectionner la portion de la ligne de cache qui nous intéresse, grâce à un paquet de multiplexeurs. Cela permet d'effectuer une lecture ou écriture, mais il faut aussi préciser si il y a eu un défaut de cache ou un succès. Un succès de cache a lieu quand au moins des comparaisons est positive, alors que c'est un défaut de cache sinon. En clair, détecter un succès de cache demande juste de connecter une porte OU à plusieurs entrées à tous les comparateurs. [[File:Organisation générale d'un cache totalement associatif.png|centre|vignette|upright=2|Organisation générale d'un cache totalement associatif.]] ===Les caches directement adressés=== Les caches directement adressés peuvent être vus comme un cache totalement associatif auquel on aurait ajouté des restrictions assez drastiques. Plus haut, on a vu qu'un cache totalement adressé est équivalent à la combinaison d'une CAM avec une RAM. La mémoire CAM prend en entrée un Tag et traduit celui-ci en une adresse qui commande la mémoire RAM interne au cache. Dans ce qui suit, l'adresse interne au cache sera appelé l''''indice''' pour éviter toute confusion. [[File:Cache hash table - 2.png|centre|vignette|upright=2|Fonctionnement interne du cache, expliquée sous forme abstraite, en utilisant la notion d'indice interne au cache.]] Les caches directement adressés cherchent à remplacer la mémoire CAM par un circuit combinatoire. Ce circuit traduit le Tag en indice, mais est beaucoup plus simple qu'une mémoire CAM. Mais qui dit circuit plus simple dit circuit plus limité. Un circuit combinatoire n'est pas aussi versatile que ce qui est permis avec une mémoire CAM. En conséquence, une restriction majeure apparait : toute adresse mémoire est associée dans une ligne de cache prédéfinie, toujours la même. L'association entre ligne de cache et adresse mémoire est faite par le circuit combinatoire, et ne peut pas changer. Les concepteurs de caches s'arrangent pour que des adresses consécutives en mémoire RAM occupent des lignes de cache consécutives, par souci de simplicité. Tout se passe comme suit la mémoire RAM était découpés en blocs de la même taille que le cache. La première adresse du bloc est associée à la première ligne de cache (celle d'indice 0), la seconde adresse est associée à la seconde adresse du_ bloc, et ainsi de suite. Le tout est illustré ci-dessous. [[File:Cache adressé directement.png|centre|vignette|upright=2|Cache adressé directement.]] Avec cette contrainte, le circuit de traduction de l'adresse en adresse mémoire pour la RAM interne au cache est drastiquement simplifié, et disparait même. Une partie de l'adresse mémoire sert à indiquer la position de la donnée dans le cache, le reste de l'adresse sert encode le tag et la position de la donnée dans le ligne de cache. [[File:Cache line.png|centre|vignette|upright=2|Adresse d'une ligne de cache sur un cache adressé directement.]] Un cache directement adressé est conçu avec une RAM, un comparateur, et un paquet de multiplexeurs. En général, la mémoire RAM stocke les lignes de caches complète. Il arrive que l'on utilise deux mémoires RAM : une pour les tags et une pour les données, mais cette technique augmente le nombre de circuits et de portes logiques nécessaires, ce qui réduit la capacité du cache. L'index à lire/écrire est envoyé sur l'entrée d'adresse de la RAM, la RAM réagit en mettant la ligne de cache sur sa sortie de donnée. Sur cette sortie, un comparateur compare le tag de la ligne de cache lue avec le tag de l'adresse à lire ou écrire. On saura alors si on doit faire face à un défaut de cache. Ensuite, un multiplexeur récupère la donnée à lire/écrire. [[File:Direct mapped cache - french.png|centre|vignette|upright=2|Cache directement adressé.]] L'accès à un cache directement adressé a l'avantage d'être très rapide vu qu'il suffit de vérifier une seule ligne de cache : celle prédéfinie. Mais ces caches ne sont cependant pas sans défauts. Vu que le cache est plus petit que la mémoire, certaines adresses mémoires se partagent la même ligne de cache. Si le processeur a besoin d’accéder fréquemment à ces adresses, chaque accès à une adresse supprimera l'autre du cache : tout accès à l'ancienne adresse se soldera par un défaut de cache. Ce genre de défauts de cache causés par le fait que deux adresses mémoires ne peuvent utiliser la même ligne de cache s'appelle un '''défaut par conflit''' (''conflict miss''). Les défauts par conflit n'existent pas sur les caches totalement associatifs. En conséquence, le taux de succès des caches directement adressés est assez faible comparé aux autres caches. [[File:Cache Block Basic Conflict.svg|centre|vignette|upright=1.5|Exemple de ''Conflict Miss''.]] ===Les caches associatifs par voie=== Les caches associatifs par voie sont un compromis entre les caches directement adressés et les caches totalement associatifs. Pour simplifier, ces caches sont composés de plusieurs caches directement adressés accessibles en parallèle, chaque cache/RAM étant appelé une '''voie'''. Avec ces caches, toute adresse mémoire en RAM est associée à une ligne de cache dans chaque voie. [[File:Cache associatif par voie.png|centre|vignette|upright=2|Cache associatif par voie.]] Le schéma ci-dessous compare un cache directement adressé et un cache associatif à deux voies. On voit que chaque adresse est associée à une ligne de cache bien précise avec un cache directement dressé, et à deux lignes de cache avec un cache associatif à deux voies. L'adresse sera associée à 4 lignes de cache sur un cache associatif à 4 voies, à 8 lignes pour un cache à 8 voies, etc. L'ensemble des lignes de cache associées à une adresse est appelé un '''ensemble'''. [[File:Cache Fill.svg|centre|vignette|upright=2|Comparaison entre un cache directement adressé et un cache associatif à deux voies.]] Sur ces caches, toute adresse est découpée en trois parties : un tag, un index, et un décalage, comme sur les caches directement adressés. Comme vous pouvez le voir, l'organisation est identique à celle d'un cache totalement associatif, à part que chaque ensemble tag-ligne de cache est remplacé par une mémoire RAM qui en contient plusieurs. [[File:Implémentation d'un cache associatif par voie.png|centre|vignette|upright=2|Implémentation d'un cache associatif par voie.]] Le risque de conflits d'accès au cache est donc réduit sur un cache associatif à plusieurs voies, et il est d'autant plus réduit que le cache a de voies. Par contre, leur conception interne fait qu'ils ont un temps d'accès légèrement élevé que les caches directement adressés. Les caches associatifs par voie ont donc un taux de succès et un temps d'accès intermédiaire, situé entre les caches directement adressés et totalement associatifs. Ils sont une sorte de compromis entre réduction des défaut par conflits d'accès au cache et temps d'accès, et complexité des circuits. ==Les optimisations des caches associatifs par voie== Les caches partiellement associatifs regroupent les caches associatifs par voie et directement adressés, ainsi que leurs variantes. En clair : tous les caches qui ne sont pas totalement associatifs. Ils peuvent être optimisés de nombreuses manières, que ce soit pour gagner en performance ou pour économiser de l’énergie. Dans cette section, nous allons voir quelles sont ces optimisations. ===Les caches pseudo-associatifs=== Les caches adressés par voie contiennent une mémoire SRAM par voie. En théorie, les voies sont accédées en parallèles, en même temps, afin de voir si l'on a un succès de cache ou un défaut. Les '''caches pseudo-associatifs''' sont identiques aux caches associatifs par voie, si ce n'est qu'ils vérifient chaque voie une par une. Ils ont été utilisés sur des processeurs commerciaux, un exemple étant l'IBM 370. Là encore, on perd en performance pour gagner en consommation d'énergie. Le temps d'accès dans le meilleur des cas est plus faible pour les caches pseudo-associatifs, mais le pire des cas teste tous les caches avant de tomber sur le bon. Les performances sont donc réduites. Mais la consommation énergétique est meilleure, vu qu'on ne vérifie pas forcément toutes les voies en parallèle. On teste la première voie, éventuellement la seconde, peut-être la troisième, etc. Mais dans le cas général, on ne teste qu'une partie des voies, pas toutes, ce qui donne un gain en termes d'énergie. L'implémentation de caches de ce genre demande que l'on parcoure les voies une par une, en commençant de la première jusqu'à la dernière. Pour cela, un simple compteur suffit. Suivant la valeur du compteur, la voie associée est activée puis accédée. Toute la complexité revient à ajouter un circuit qui prend la valeur du compteur, et active la voie associée, lance un accès mémoire dessus. Vu que les voies sont chacune des caches ''direct mapped'', il suffit pour cela de geler les entrées d'adresse, soit en les déconnectant, soit en utilisant du ''clock gating'' ou de l'évaluation gardée. Les détails d'implémentation, non-cités ici, varient selon le cache. ===La prédiction de voie=== Pour réduire le temps d'accès des caches pseudo-associatifs, certains chercheurs ont inventé la '''prédiction de voie''', qui consiste à faire des paris sur la prochaine voie accédée. L'idée est d'accéder à la voie qui contient la donnée voulue du premier coup, en lisant celle-ci en priorité. Dans son implémentation la plus simple, le cache reste un cache pseudo-associatif. Lors d'un accès au cache, les voies sont toutes parcoures une par une. Par contre, les voies ne sont donc pas parcourues de la première vers la dernière, mais dans un ordre différent. Cette technique permet de mettre en veille les voies sur lesquels le processeur n'a pas parié, ce qui permet de diminuer la consommation énergétique du processeur. C'est plus efficace que d'aller lire plusieurs données dans des voies différentes et de n'en garder qu'une. L'implémentation est assez simple : il suffit d'ajouter un circuit de prédiction de voie,relié au compteur de voie. Une amélioration de la technique fait fonctionner le cache comme un intermédiaire entre cache pseudo-associatif et associatif par voies. L'idée est de chercher la voie prédite en premier, puis de chercher dans toutes les voies en parallèle en cas de défaut de cache. Au lieu d'attendre que les comparaisons de tags donnent leur résultat, le processeur sélectionne automatiquement une voie et configure les multiplexeurs à l'avance. Si le processeur ne se trompe pas, le processeur accède à la donnée plus tôt que prévu. S'il se trompe, le processeur annule la lecture effectuée en avance et recommence en faisant un accès en parallèle aux autres voies. Le compromis entre performance et consommation d'énergie est alors différent. On économise de l'énergie par rapport à un cache associatif par voie, au prix d'une petite perte de performance (doublement des temps d'accès). Mais par rapport à un cache pseudo-associatif, l'économie d'énergie est bien moindre, au prix d'un gain en performance assez manifeste. Prédire quelle voie sera la bonne est assez simple. En vertu du principe de localité, les accès futurs ont des chances de tomber dans les voies les plus fréquemment utilisées ou dans celle plus récemment utilisée. Il suffit de retenir la voie la plus récemment accédée dans un registre, qui sera utilisée comme prédiction. Pour vérifier que la prédiction est correcte, il suffit de comparer le registre et le résultat obtenu après vérification des tags. Cependant, on peut complexifier l'implémentation pour prendre en compte l'adresse à lire/écrire, l'instruction à l'origine de l'accès mémoire ou tout autre paramètre utile. Par exemple, des instructions différentes ont tendance à aller chercher leurs données dans des ensembles différents et la voie à choisir n'est pas la même. Pour cela, il suffit d'utiliser un cache pour stocker la correspondance instruction - voie. Pour plus de simplicité, la mémoire cache des prédictions est parfois remplacée par une RAM, qui est adressée : * soit par le program counter de l'instruction à l'origine de l'accès (en réalité, seulement quelques bits de poids faible de l'adresse) ; * soit par l'adresse à accéder (là encore, quelques bits de poids faible) ; * soit (pour les modes d'adressage qui utilisent un registre de base et un décalage) par un XOR entre les bits de poids faible de l'adresse de base et le décalage ; * soit par autre chose. ===La mise en veille sélective des voies=== Les caches associatifs ont tendance à utiliser beaucoup d'énergie, même quand on n'y accède pas. Aussi, certains processeurs détectent quand le cache est peu utilisé et en profitent pour mettre en veille les voies inutilisées. Vous vous demandez certainement ce qui se passe quand une donnée à lire/écrire est dans une voie désactivée. La réponse est que le cache détecte cette situation, car elle déclenche un succès de cache. Les ''tags'' ne sont en effet pas désactivés, seules les données sont mises en veille. L'implémentation est plus simple sur les caches qui séparent les tags et les données dans deux RAM différentes. Cette optimisation marche surtout sur les gros caches, qui ont des chances d'avoir une portion significative d’inutilisée (pas assez de données pour les remplir), donc généralement les caches L3/L4. Par exemple, les processeurs d'Intel de microarchitecture Ivy Bridge disposent d'un cache de 8 mébioctets à 16 voies, qu'ils peuvent faire passer à 512 kibioctets si le besoin s'en fait sentir. Quand ces processeurs détectent une faible activité, ils mettent en veille 14 voies et n'en gardent que 2 d'actives. Évidemment, les 14 voies sont vidées avant d'être mises en veille, afin qu'une aucune donnée ne soit perdue. ===Les caches ''skew-associative''=== Vous aurez remarqué que dans une voie, les lignes sont accédées en adressage direct : les défauts par conflit sont possibles sur un cache associatif par voie. Pour éviter cela, certains chercheurs ont créé des '''caches ''skew associative''''' (ou associatifs à biais). Pour faire simple, les index des lignes de cache subissent un petit traitement avant d'être utilisés. Le traitement en question est différent suivant la voie de destination, histoire que deux adresses mémoires avec des index identiques donnent des index différents après traitement. Le traitement en question est souvent une permutation des bits de l'index, qui est différente suivant la voie prise, ou un simple XOR avec un nombre qui dépend de la voie. [[File:Implémentation d'un cache skew associative.jpg|centre|vignette|upright=2|Implémentation d'un cache skew associative.]] ==Les caches splittés (''phased caches'')== Dans cette section, nous allons voir les '''caches splittés''' (''phased caches''), qui sont une variante des caches ''direct-mapped'', dans lequel le cache est accédé en deux étapes consécutives. Il ne s'agit pas des caches pipelinés, que nous verrons dans le chapitre sur les processeurs pipélinés, mais laissons cela à plus tard. Il est possible d'appliquer la même méthode sur un cache associatif par voie, mais il y a des méthodes plus simples, qui permettent là aussi d’accéder au cache en plusieurs étapes consécutives. L'idée est de scinder le cache en deux : une mémoire pour les tags, une autre pour les données de la ligne de cache. Les bits de contrôle peuvent être mis dans l'une ou l'autre SRAM, mais ils sont souvent mis dans la RAM pour les tags. En faisant cela, quelques optimisations deviennent possibles, afin de réduire la consommation énergétique en contrepartie d'une perte de performance. La technique s'implémente différemment pour les caches totalement associatifs et partiellement associatifs. Les caches totalement associatifs splittés sont ceux formés en combinant un cache associatif avec une CAM et une RAM combinée. On envoie l'adresse à lire/écrire à la mémoire associative, elle répond en envoyant une adresse à la mémoire RAM. L'accès se fait donc en deux temps, avec l'adresse dans la RAM comme intermédiaire. Il est possible de séparer physiquement les deux étapes en insérant un registre entre la CAM et la RAM, ce qui permet aussi de pipeliner l'accès. Mais c'est rarement fait en pratique, car le cout en circuit d'une mémoire CAM est trop important. L'équivalent pour un cache totalement associatif optimisé, sans CAM et RAM séparée, est trop gourmande en interconnexions pour être implémentée. Les caches totalement associatifs splittés sont donc très rares, l'auteur ne connait aucun exemple de processeur avec un tel cache. Il existe une technique équivalente pour les caches ''direct-mapped'', mais elle demande une certaine modification du cache. Dans les caches ''direct-mapped'' non-splittés, on trouve une mémoire SRAM dont chaque mot mémoire contient une ligne de cache entière, tag inclus. Dans leurs versions splittés, la SRAM est séparée en deux : une pour les tags, une autre pour les données. Précisons qu'il s'agit bien de deux mémoires SRAM adressables. L'adresse à laquelle accéder est envoyée à la SRAM des tags, puis ensuite à la SRAM des données si besoin. L'idée est d’accéder aux tags pour déterminer s'il y a un succès de cache ou un défaut, et ensuite d'accéder aux données. On n’accède pas aux données en parallèle des tags. Faire cela est évidemment plus lent. En cas de défaut de cache, le temps d'accès est similaire : le tag ne correspond pas, on n'accède pas à la SRAM pour les données. Par contre, vu qu'on n'a pas activé la SRAM pour les données, on économise un peu d'énergie, ce qui réduit la consommation d'énergie. En cas de succès de cache, on accède à la SRAM pour les tags, puis à celle pour les données. Pas d'économie d'énergie à l'horizon, sans compter que le temps d'accès augmente : on accède au cache en deux étapes au lieu de faire les deux accès en parallèle. [[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]] Précisons cependant que ce design peut avoir deux avantages en termes de performance. Premièrement, le temps d'accès au cache est légèrement amélioré en cas de défaut de cache. En effet, la SRAM des tags est assez petite, idem pour celle des données. Leur temps d'accès est donc plus faible que pour une grosse SRAM contenant données et tags. Le gain en temps d'accès est donc un avantage, qui ne se manifeste surtout en cas de défaut de cache. Un autre avantage est que l'accès au cache se pipeline plus facilement, ce qui fait qu'on peut effectuer plusieurs accès simultanés au cache. Mais nous verrons cela dans quelques chapitres. ===L'exemple des processeurs 386 et du contrôleur de cache 82385=== Il est important de noter que la séparation entre tags et RAM peut être telle que les deux ne sont pas sur la même puce de silicium ! Voire que les deux sont séparés du processeur ! C'était le cas quand les mémoires caches ont été introduites sur les processeurs grand public, notamment sur les premiers processeurs Intel. La miniaturisation n'avait pas avancé au point où placer un cache dans le processeur était possible. Sur le processeur 386 d'Intel, le cache était un cache splitté, séparé du processeur. Concrètement, le processeur i386 était couplé à un contrôleur de cache Intel 82385 et une mémoire SRAM. Le 82385 contenait les ''tags'' et les bits de contrôle, la SRAM contenait les données, les lignes de cache. Un point important est que les lignes de cache faisaient seulement 32 bits/4 octets, pas plus ! On était loin des lignes de cache actuelles, faisant 64 octets/512 bits. Mais c'était beaucoup plus pratique, vu que le bus système faisait 32 bits de large, idem pour l'interface avec le processeur. Pour intégrer un cache facultatifs (certains ordinateurs 'en passaient). Le schéma ci-dessous montre comment le cache s'intégrait avec le bus système. Pour le bus de commande, le cache servait d'intermédiaire : il recevait les commandes et et les filtrait suivant les succès/défauts de cache. En cas de succès de cache, les commandes de lecture n'étaient pas envoyées à la mémoire RAM. Les adresses étaient transmises à la fois au cache et au bus système (avec un registre entre le bus système et le processeur). Le bus de donnée était lui connecté à la mémoire SRAM et au processeur, avec des MUX/DEMUX pour faire le choix de la source des lectures. [[File:Controleur de cache 82385 pour l'Intel 386.png|centre|vignette|upright=2.5|Contrôleur de cache 82385 pour l'Intel 386]] Le 82385 surveillait ce qui se passait sur le bus et répondait à la place de la RAM pour certaines lectures. C'était un intermédiaire assez passif, qui se contenait de répondre aux succès et défauts en lecture. Le cache était un cache ''write through'' un peu particulier. En cas de succès de cache pour une écriture, le cache met à jour sa ligne de cache et propage l'écriture en mémoire RAM. Par contre, si une écriture fait un défaut de cache, la donnée n'est pas écrite dans le cache. Le seul moyen pour copier une donnée dans le cache était un défaut pour une lecture. Le 82385 pouvait commander soit un cache ''direct mapped'', soit associatif à deux voies. La différence entre les deux est que le 82385 était relié à une mémoire SRAM avec un cache ''direct mapped'', deux SRAM pour deux voies. Pour avoir un cache associatif à deux voies, le 82385 devrait gérer deux signaux ''chip select'' pour activer chaque SRAM/voie suivant les besoins. Il avait précisément quatre signaux CS : deux par SRAM, un pour les lectures, un pour les écritures. Notons que les SRAM utilisées devaient être des SRAM avec un bus de données de 32 bits, des SRAM 16 ou 8 bits ne fonctionnaient pas. Le 82385 rajoutait 4 sorties, pour masquer chaque octet dans ces 32 bits. Le choix entre les deux était le fait d'une entrée : la mettre à 0 indiquait un cache ''direct mapped'', la mettre à 1 forçait un cache à deux voies. Il gérait aussi les accès mémoire non-cacheable, à savoir des accès mémoire qui ne doivent pas être pris en compte par le cache. Il considérait certains accès mémoire comme "à ne pas cacher". Notamment, les accès mémoire à une entré-esortie ne sont pas cachés. Pour rappel, le processeur utilisait un espace d'adressage séparé pour les entrées-sorties, et utilisait donc un bit IO, qui était utilisé par le 82385 pour savoir si l'accès mémoire doit être caché ou non. Il en est de même pour les accès ayant lieu lors d'une interruption, qui ne passent pas par le cache. Mais au-delà de cette inhibition automatique du cache, le 82385 avait une entrée NCA (''Non Cacheable Access'') : le cache était "désactivé" quand cette entrée était à 1. C'est un peu une sorte de ''chip select'' pour le 82385, limitée aux accès mémoire. Cette entrée permettait de programmer des intervalles d'adresse auxquels ne pas répondre, en utilisant des circuits de décodage d'adresse adaptés. Il avait aussi une entrée X16, qui permettait d'identifier les accès soit à un composant 16 bits. De tels accès ne doivent pas être mis en cache, sans doute parce que cela ne collait pas avec la taille des lignes de cache (32 bits). Et cette entrée permettait d'inhiber ces accès 16 bits d'agir sur le cache, en utilisant le bit du bus de commande adéquat. Cette surveillance du bus permettait de gérer une forme limitée de cohérence des caches par invalidation. Dès que le 82385 détectait une prise de contrôle du bus par autre chose que le processeur, il surveillait les adresses transmises sur le bus. En cas de succès de cache, la ligne de cache associée était invalidée. Au-delà de ca, le 82385 avait une entrée FLUSH, qui ordonnait une invalidation totale du cache. Si cette entrée est mise à 1, toutes les lignes de cache sont invalidées. Les ''tags'' sont marqués comme invalides, mais les lignes de cache elles-mêmes ne sont pas touchées. Le 82385 pouvait être intégré dans un système à deux processeurs, voire plus. Pour cela, chaque processeur avait son propre 82385 et sa SRAM rien qu'à lui. Il n'y avait pas de cache partagé entre les deux processeurs. Par contre, les deux caches étaient reliés au même bus système. Pour qu'ils ne se marchent pas sur les pieds, il y avait des circuits d'arbitrage pour gérer l'accès au bus. Un des deux 82385 était mis en mode maitre, l'autre était en mode esclave. Le 82385 maitre pouvait prendre le contrôle du bus, le 82385 esclave devait demander l'autorisation au premier pour accéder au bus système. ===L'exemple des processeurs Intel de microarchitecture ''Broadwell''=== Un autre exemple est celui du cache L4 des processeurs Broadwell et de quelques processeurs séparés. Ces processeurs ont une organisation en ''chiplet'' où le processeur incorpore plusieurs puces séparées : une puce pour le processeur proprement dit, une puce nommée ''Crystal Well'' pour le cache L4, et une puce IO pour la communication avec la RAM et la carte mère. Le processeur incorporait un cache L4 de 128 mébioctets, composé de mémoire eDRAM, qui était dispersé entre ''Crystal Well'' et les autres puces. Les données du cache L4 étaient dans ''Crystal Well'', alors que les Tags étaient soit dans le processeur lui-même, soit dans la puce IO ! La puce ''Crystal Well'' était une mémoire DRAM adressable tout ce qu'il y a de plus basique, avec cependant quelques optimisations notables. Par exemple, elle avait deux bus séparés pour l'écriture et la lecture. De plus, elle avait une organisation interne avec 128 banques, contre moins d'une dizaine pour la DDR de l'époque et environ 32 banques pour la DDR5 moderne. Elle contenait aussi quelques circuits pour gérer son rôle de mémoire cache, mais rien en ce qui concerne la gestion des tags eux-mêmes. Sur les processeurs de microarchitecture ''Broadwell'', les tags étaient placés dans le CPU et précisément dans le cache L3. À chaque accès mémoire au cache L3, les tags du cache L4 étaient consultés en parallèle. De fait, l'accès au cache L4 était assez rapide, malgré le fait que les données étaient dans une puce à part. Ajoutons à cela que le processeur et ''Crystal Well'' n'avaient pas la même finesse de gravure ni la même technologie de fabrication. Les tags étaient implémentés avec de la SRAM contre la DRAM pour les données, ce qui fait que la consultation des tags était plus rapide que l'accès aux données. Par la suite, dans certains CPU de microarchitecture ''skylake'', les tags ont été déplacés en-dehors du processeur pour finir dans le contrôleur mémoire. En faisant cela, le cache L4 pouvait être utilisé par autre chose que le processeur, et notamment par la carte graphique intégrée au CPU. Avec ''broadwell'', le fait que les tags étaient consultés en cas d'accès au L3 empêchait au GPU intégré de consulter le cache L4. Mais en déplaçant les tags dans le contrôleur mémoire, ce n'est plus le cas vu que la carte graphique a aussi accès au bus mémoire. Par contre, le temps d'accès augmente comparé à la solution précédente. On n'accède pas aux tags du L4 en parallèle du L3 : à la place, il faut consulter les tags du L3, détecter un défaut de cache L3, et ensuite accèder aux tags. ===Les caches RAM-configurables=== Un autre avantage des caches splittés est qu'on peut les modifier pour servir à la fois de mémoire cache, mais aussi de ''local store'', de mémoire RAM de petite taille. Le fonctionnement est assez simple à comprendre. Lors d'un accès au cache, on accède aux tags, puis à la RAM interne au cache. Lors d'un accès au ''local store'', on contourne l'accès au tags et on accède à la RAM interne au cache directement. Il s'agit de la technique du '''cache RAM-configurable''. L'usage de cache RAM-configurable est fréquent sur les cartes graphiques récentes, qui incorporent un ou plusieurs processeurs multicoeurs, dont le cache L1 de données est un cache RAM-configurable. [[File:Hydride cache - local store.png|centre|vignette|upright=2.0|Hydride cache - local store]] ===La compression de cache=== Une autre optimisation permise par les ''phased caches'' est l'implémentation de techniques de '''compression de cache''', qui visent à compresser des lignes de cache. L'intérêt est qu'on peut stocker plus de données dans le cache, à capacité égale. L'inconvénient est qu'on doit compresser/décompresser les lignes de cache, ce qui demande un circuit en plus et allonge les temps d'accès. En effet, le temps mis pour compresser/décompresser une ligne de cache s'ajoute au temps d'accès. Aussi, la compression de cache sert surtout pour les caches de bas niveau dans la hiérarchie mémoire, les gros caches aux temps d'accès assez longs. Une première technique, assez simple à implémenter et peu couteuse en circuit, est celle de la '''compression des lignes de cache nulles'''. Elle compresse uniquement les lignes de cache qui ne contiennent que des zéros. L'idée est qu'on ajoute, dans la mémoire des tags, un bit de contrôle pour chaque ligne de cache appelé le bit ''null''. Il indique si la ligne de cache ne contient que des zéros. Quand on lit une ligne de cache, la mémoire des tags est accédée et on vérifie le bit ''null'' : s'il vaut 1, on n'accède pas à la mémoire cache de données et un multiplexeur envoie un zéro sur le port de lecture. Le bit ''null'' est fixé lors de l'écriture d'une ligne de cache : elle passe dans un comparateur avec zéro relié à la mémoire des tags. La comparaison avec zéro peut se faire en parallèle de l'écriture ou avant (dans ce cas, on n'écrit pas la ligne de cache dans le cache). Les autres techniques de compression de cache permettent de compresser autre chose que des lignes de cache nulles. L'idée est qu'une ligne de cache physique peut par moment mémoriser plusieurs lignes de caches compressées. Par exemple, prenons un cache dont les lignes de cache font 64 octets. Il est possible de compresser deux lignes de cache pour qu'elles fassent chacune 32 octets, et les stocker dans une seule ligne de cache. Les deux lignes de cache auront des tags différents, mais pointeront sur la même ligne de cache physique. Et cela demande d'utiliser un ''phased cache'' dont la mémoire pour les tags est plus grande que la mémoire pour les données. Il n'y a donc plus une bijection entre tags et ligne de cache, mais une relation surjective. Chose qui n'est possible qu'avec un ''phased cache''. De plus, des bits de contrôles associés à chaque ''tag'' indiquent où se trouvent les lignes de cache compressées dans la ligne de cache : est-ce que c'est les 32 octets de poids fort ou de poids faible ? [[File:Compression de cache.png|centre|vignette|upright=2|Compression de cache]] Il ne semble pas que les techniques de compression de cache soient implémentées sur les processeurs modernes. Aucun n'utilise de compression de cache, à ma connaissance. Il faut dire que les techniques connues sont de mauvais compromis : le temps d'accès du cache augmente beaucoup, le cout en circuit pourrait être utilisé pour un cache non-compressé mais plus grand. Et notons que la compression de cache ne marche que si les données peuvent se compresser. Si ce n'est pas le cas, une partie de la mémoire des tags est inutilisée. Une revue de la littérature académique sur la compression de cache est disponible via ce lien, pour les curieux : * [https://inria.hal.science/hal-03285041 Understanding Cache Compression, par Carvalho et Seznec]. ==L'adressage physique ou logique des caches== Le cache utilise les adresses à lire/écrire pour déterminer s'il a une copie de la donnée en son sein. Mais l’interaction entre caches et mémoire virtuelle donne lieu à un petit problème : l'adresse utilisée est-elle une adresse virtuelle/logique ou physique ? La réponse varie suivant le processeur : certains caches utilisent l'adresse virtuelle, tandis que d'autres prennent l'adresse physique. On parle de cache '''virtuellement tagué''' dans le premier cas et de cache '''physiquement tagué''' dans le second. {| |[[File:Cache tagué virtuellement.png|vignette|Cache tagué virtuellement.]] |[[File:Cache tagué physiquement.png|vignette|Cache tagué physiquement.]] |} ===L'accès à un cache physiquement/virtuellement tagué=== La manière d'accéder à un cache dépend de s'il est virtuellement ou physiquement tagué. Il faut utiliser l'adresse virtuelle pour les premiers, physique pour les seconds. Avec un cache virtuellement tagué, l'adresse logique peut être envoyée directement au cache. La MMU ne traduit les adresses que s'il faut accéder à la mémoire RAM. Ces caches sont donc plus rapides. Avec un cache physiquement tagué, le processeur doit traduire l'adresse logique en adresse physique dans la MMU, avant d'accéder au cache. La traduction d'adresse se fait soit en accédant à une table des pages en mémoire RAM, soit en accédant à un cache spécifiquement dédié à accélérer la traduction d'adresse, la TLB (''Translation Lookaside Buffer''). Dans la quasi-totalité des cas, la traduction d'adresse passe par la TLB, ce qui fait qu'elle est raisonnablement rapide. Toujours est-il que chaque accès au cache demande d'accéder à la TLB et de faire la traduction d'adresse avant d'accéder au cache. L'accès est donc plus lent que sur les caches virtuellement tagués, où les accès sont plus directs. [[File:Virtual and Physical addressing.svg|centre|vignette|upright=2|Cache tagué virtuellement versus physiquement tagué.]] ===Les défauts des caches virtuellement tagués=== Les caches physiquement tagués sont moins rapides que les caches virtuellement adressés. Pourtant, les caches virtuellement tagués sont peu fréquents sur les processeurs modernes. Et la raison est assez intéressante : c'est une question d'adresses homonymes et synonymes. ====Les droits d'accès doivent être vérifiés lors d'un accès au cache==== Un premier problème est que la protection mémoire est compliquée avec de tels caches. Rappelons que certaines portions de mémoire sont accessibles seulement en lecture, ou sont interdites en écriture, sont inexécutables, etc. Ces droits d'accès sont gérés par la MMU, qui vérifie pour chaque accès mémoire que l'accès est autorisé. En bypassant la MMU, l'accès au cache virtuellement tagué ne permet pas de faire ces vérifications. Il est possible de charger une donnée en lecture seule dans le cache, mais d'y faire des accès en écriture pour les accès ultérieurs. Les solutions à cela sont multiples. La première consiste à consulter la MMU en parallèle de l'accès au cache. L'accès au cache est alors réalisé de manière spéculative, et est ensuite confirmé/annulé une fois que la MMU a rendu son verdict. Les performances du cache restent alors les mêmes : l'accès à la MMU se fait en parallèle de l'accès au cache, pas avant. Une autre solution est d'ajouter les droits d'accès en question dans la ligne de cache, dans les bits de contrôle situés après le Tag. Chaque accès au cache récupère ces bits de contrôle et vérifie si l'accès est autorisé. L'inconvénient est que les lignes de cache deviennent plus longues, les droits d'accès sont dupliqués entre MMU et cache. Mais si le budget en transistor suit, ce n'est rien d'insurmontable. ====Les adresses homonymes perturbent la gestion du cache==== Pour rappel, une adresse logique homonyme correspond à plusieurs adresses physiques différentes. Elles surviennent quand chaque programme a son propre espace d'adressage. Dans ce cas, une adresse logique correspondra à une adresse physique différente par programme.Une autre manière de voir les choses est qu'il y a en réalité deux adresses homonymes, qui ont la même valeur, mais appartiennent à des espaces d'adressage différentes. Et c'est cette seconde interprétation que nous allons utiliser. Les caches doivent gérer ces adresses homonymes et faire en sorte que la lecture/écriture d'une adresse homonyme se fasse à la bonne adresse physique, dans la bonne ligne de cache. Et autant un cache physiquement tagué n'a aucun problème avec ça, vu qu'il ne gère que des adresses physiques, autant des problèmes surviennent avec les caches virtuellement tagués. Le problème est que les caches virtuellement tagués doivent faire la différence entre deux adresses homonymes de même valeur. Pour corriger ces problèmes, il existe deux grandes méthodes. La première méthode est simple : '''vider les caches''' en changeant de programme. Leur contenu est rapatrié en mémoire RAM, puis les caches sont remis à zéro. Le vidage du cache recopie les lignes de cache ''dirty'' (modifiées) en RAM, puis efface/invalide tout le cache. C'est à cela que servent les instructions ''clean'' et d'invalidation vues plus haut, elles ont été inventées pour cette situation précise. Lorsque le système d'exploitation déclenche une commutation de contexte, à savoir qu'il change le programme en cours d'exécution, le processeur vide tous les caches du processeur. Les interruptions font la même chose, elles vide tous les caches du processeur. Une seconde méthode numérote chaque programme en cours d'exécution, chaque processus. Le numéro attribué est spécifique à chaque processus, ce qui fait qu'il est appelé un '''identifiant de processus CPU'''. Le processeur mémorise l'identifiant du programme en cours d'exécution dans un registre dédié. L'identifiant de processus CPU est utilisé lors des accès mémoire. Chaque ligne de cache contient le numéro de l'espace d'adressage associé, dans son ''tag''. Lors de chaque accès mémoire, l'ID du registre est comparé à l'ID de la ligne de cache accédée, pour vérifier que l'accès mémoire accède à la bonne donnée. Cette méthode n'est pas très économe en termes de transistors. L'usage d'identifiant de processus CPU est clairement meilleure en termes de performance, les commutations de contexte sont plus rapides. Par contre, le budget en transistor est plus important. Un autre défaut de cette méthode est que l'identifiant de processus est généralement codé sur une dizaine de bits, alors que le système d'exploitation utilise des identifiants de processus beaucoup plus larges, de 32 à 64 bits sur les CPU 32/64 bits. L'OS doit gérer la correspondance entre identifiants de processus CPU et ceux de l'OS. Parfois, pour cette raison, les OS n'utilisent pas toujours ce système d'identifiant de processus CPU. ====Les adresses synonymes perturbent aussi la gestion du cache==== La gestion des adresses synonymes est aussi un gros problème sur les caches virtuellement tagués. Pour rappel, il s'agit du cas où des adresses logiques différentes pointent vers la même adresse physique. Typiquement, quand deux programmes se partagent un morceau de mémoire, ce morceau correspondra à des adresses synonymes dans les deux espaces d'adressage. Mais il arrive que l'on ait des adresses synonymes dans le même espace d'adressage, ce n'est pas si rare ! Autant les adresses synonymes ne posent aucun problème avec les caches physiquement tagués, ce n'est pas le cas avec les caches virtuellement adressés. Sur ces caches, deux adresses logiques synonymes vont tomber dans deux lignes de cache différentes. Corriger ce problème demande d'ajouter des circuits annexes pour détecter les adresses synonymes, qui sont vraiment complexes et ont un cout en termes de performance. Aussi, les caches virtuellement tagués sont très peu utilisés sur les processeurs modernes. ===Les caches virtuellement adressés, mais physiquement tagués=== Si les caches physiquement et virtuellement tagués ont des défauts, il existe un intermédiaire qui est un bon compromis entre ces deux extrêmes. Il s'agit des '''caches virtuellement adressés - physiquement tagués''', aussi appelés '''caches pseudo-virtuels'''. Pour comprendre comment ils fonctionnent, précisons que ces caches sont soit des caches ''direct-mapped'', soit des caches associatifs par voie (composés de plusieurs RAM ''direct-mapped'' accédées en parallèle, plusieurs voies). L'accès à ce genre de cache se fait en deux temps : on accède à un ou plusieurs RAM ''direct-mapped'' et on vérifie ensuite les ''Tags'' pour sélectionner la bonne voie. Sur les caches ''direct-mapped'', on n'a qu'une seule RAM ''direct-mapped''. Sur les caches associatifs, on a plusieurs RAM ''direct-mapped'', appelées des voies, qui sont accédées en parallèle. L'accès se fait donc en deux étapes : adresser les RAM ''direct-mapped'' avec un indice, vérifier les ''tags'' avec le reste de l'adresse. Une autre chose à rappeler est que l'adresse logique est composée de deux parties : un numéro de page logique qui indique dans quel page se situe l'adresse, un décalage/''offset'' qui indique la position de l'adresse dans la page. La traduction d'adresse transforme le numéro de page logique en numéro de page physique, mais laisse le décalage intouché. L'idée est d'utiliser le décalage pour adresser les RAM avec le décalage, tandis que le numéro de page sert de ''tag''. Le décalage est découpé en deux lors de l'accès au cache : les bits de poids fort forment l'indice (l'adresse envoyée à la voie), les bits de poids faible donnent la position de l'adresse dans la ligne de cache. L'idée est d'utiliser un numéro de page physique pour les ''tags'', mais d'adresser les voies avec le décalage logique. Les deux servent à des instants différents : vérification des ''tags'' pour l'adresse physique, accès aux voies pour l'adresse logique. Ainsi, le problème des adresses synonymes ou homonymes est résolu par l'utilisation de l'adresse physique pour les tags. Par contre, l'accès au cache est plus rapide, car on utilise l'adresse logique pour la première étape. Le processeur accède à la TLB et récupère l'adresse physique pendant que l'on adresse les voies, les deux sont faits en parallèle, ce qui fait que tout se passe comme si l'accès à la TLB était gratuit. La TLB étant assez rapide comparé au cache, l'adresse physique est disponible quand on doit faire la comparaison avec les ''tags''. [[File:Virtual - Physical - Pseudo Virtual addressing.svg|centre|vignette|upright=2|Adressage pseudo virtuel des caches.]] Il s'agit d'un excellent compromis entre performance et correction des problèmes des adresses synonymes/homonymes. Tous les caches des processeurs haute performance utilisent cette méthode, au moins pour leurs caches L1. Les caches L2 tendent à utiliser des caches physiquement adressés, pour lesquels la latence d'accès est suffisante pour qu'on accède à la TLB en amont. La raison est assez simple à expliquer, elle provient d'une contrainte assez précise sur le calcul de l'indice. La conséquence est qu'un cache ''direct-mapped'' ne peut pas dépasser la taille d'une page, soit 4 kibioctets sur les ordinateurs actuels. Sur les caches associatifs, on peut dépasser cette limite en augmentant le nombre de voies, mais la taille maximale d'une voie reste celle d'une page. Cette contrainte n'est pas trop grave sur les caches de petite taille, dont les caches L1. La plupart d'entre eux ont trouvé un compromis idéal avec moins d'une dizaine de voies par cache, chacun de 4 kibioctets, ce qui donne des caches allant de 16 à 64 kibioctets, soit entre 4 et 16 voies. Par contre, un cache de grande taille doit utiliser un grand nombre de voies, ce qui est peu pratique. Aussi, cette technique de caches pseudo-virtuels n'est pas toujours appliquée sur les caches L2, qui sont physiquement adressés. Il faut dire qu'on accède au cache L2 lors d'un défaut dans le cache L1, et l'adresse physique est disponible à ce moment-là, elle a déjà été récupérée lors de l'accès au cache L1. On peut donc l'utiliser pour adresser le cache L2 sans perte de performance. ==Le remplacement des lignes de cache== Lorsqu'un cache est rempli et qu'on charge une nouvelle donnée dedans, il faut faire de la place pour cette dernière. Dans le cas d'un cache directement adressé, il n'y a rien à faire vu que la ligne de cache à évincer est déterminée lors de la conception du cache. Mais pour les autres caches, la donnée peut aller dans n'importe quelle ligne ou voie. Or, le choix des données à rapatrier en RAM doit être le plus judicieux possible : on doit virer de préférence des données inutiles. Rapatrier une donnée qui sera surement utilisée sous peu est inutile, et il vaudrait mieux supprimer des données qui ne serviront plus ou alors dans longtemps. Il existe différents algorithmes spécialement dédiés à résoudre ce problème efficacement, directement câblés dans les unités de gestion du cache. Certains sont vraiment très complexes, aussi je vais vous présenter quelques algorithmes particulièrement simples. Mais avant de voir ces algorithmes, il faut absolument que je vous parle d'une chose très importante. Quel que soit l'algorithme en question, il choisit la ligne de cache à évincer et recopie son contenu dans la RAM. Ce qui demande d'identifier et de sélectionner une ligne de cache parmi toutes les autres. Pour cela, le circuit de remplacement attribue une adresse chaque ligne de cache ! Vous avez bien vu : chaque ligne de cache est numérotée par une adresse, interne au cache. ===Le remplacement aléatoire=== Premier algorithme : la donnée effacée du cache est choisie au hasard ! C'est contre-intuitif, mais cet algorithme donne des résultats assez honorables, en plus d'utiliser très peu de portes logiques (un générateur de nombres pseudo-aléatoire est un circuit assez simple). Généralement, les défauts de cache sont séparés par un nombre assez important et irrégulier de cycles d'horloge. Dans ces conditions, cette technique donne un bon résultat. ===FIFO : first in, first out=== Avec l'algorithme FIFO, la donnée effacée du cache est la plus ancienne, celle chargée dans le cache avant les autres. Cet algorithme est très simple à implémenter en circuit, concevoir une mémoire de type FIFO n'étant pas très compliqué, comme on l’a vu dans le chapitre dédié à ce type de mémoires. Et on peut dire que dans le cas d'un cache, l'implémentation est encore plus simple et se contente d'un seul registre/compteur. Typiquement, il suffit d'ajouter un registre qui mémorise où se situe la donnée la plus récente. Toute insertion d'une nouvelle donnée se fait à l'adresse suivante, ce qui demande juste d'incrémenter le registre avant d'utiliser son contenu pour l'accès mémoire. [[File:Algorithme FIFO de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme FIFO de remplacement des lignes de cache.]] Cet algorithme possède une petite particularité sur les caches associatifs par voie : en augmentant le nombre d'ensembles, les performances peuvent se dégrader : c'est ce qu'on appelle l''''anomalie de Bélády'''. ===MRU : most recently used=== Avec l'algorithme MRU, la donnée remplacée est celle qui a été utilisée le plus récemment. Cet algorithme s'implémente simplement avec un registre, dans lequel on place le numéro de la dernière ligne de cache utilisée. Cet algorithme de remplacement est très utile quand un programme traverse des tableaux du premier élément jusqu'au dernier : les données du tableau sont rarement réutilisées, rendant le cache inutile. Il est prouvé que dans ces conditions, l'algorithme MRU est optimal. Mais dans toutes les autres conditions, cet algorithme a des performances assez misérables. ===LFU : least frequently used=== Avec l'algorithme LFU, la donnée supprimée est celle qui est utilisée le moins fréquemment. Cet algorithme s'implémente en associant un compteur à chaque ligne de cache, qui est incrémenté à chaque accès mémoire. La ligne la moins récemment utilisée est celle dont le compteur associé a la plus petite valeur. Implémenter cet algorithme prend pas mal de transistors, car il faut rajouter autant de compteurs qu'il y a de lignes de cache, en plus d'un circuit pour comparer les compteurs et d'un encodeur. [[File:Algorithme LFU de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme LFU de remplacement des lignes de cache]] ===LRU : least recently used=== Avec l'algorithme LRU, la donnée remplacée est celle qui a été utilisée le moins récemment. Cet algorithme se base sur le principe de localité temporelle, qui stipule qu'une donnée accédée récemment a de fortes chances d'être réutilisée dans un futur proche. Et inversement, la donnée la moins récemment utilisée du cache est celle qui a le plus de chance de ne servir à rien dans le futur. Autant la supprimer en priorité pour faire de la place à des données potentiellement utiles. Implémenter l'algorithme LRU peut se faire de différentes manières, qui ont pour point commun d'enregistrer les accès au cache pour en déduire la ligne la moins récemment accédée. La manière la plus simple demande d'utiliser un compteur pour chaque ligne de mémoire cache, un peu comme le LFU. La différence avec le LFU est que le compteur n'est pas incrémenté lors d'un accès mémoire. À la place, ce compteur est incrémenté régulièrement, chaque incrémentation ayant lieu en même temps pour tous les compteurs. Quand un bloc est chargé dans le cache, ce compteur est mis à zéro. Quand une ligne de cache doit être remplacée, un circuit va vérifier la valeur de tous les compteurs : la ligne LRU (la moins récemment utilisée), est celle dont le compteur a la valeur la plus haute. Le circuit est composé d'un paquet de comparateurs, et d'un encodeur, comme pour l'agorithme LFU. ===Les approximations du LRU=== Implémenter le LRU demande un nombre de transistors proportionnel au carré du nombre de lignes de cache. Autant dire que le LRU devient impraticable sur de gros caches. Ce qui fait que les processeurs modernes implémentent des variantes du LRU, moins couteuses en transistors, qui donnent un résultat approximativement semblable au LRU. En clair, ils ne sélectionnent pas toujours la ligne de cache la moins récemment utilisée, mais une ligne de cache parmi les moins récemment utilisées. Ce n'est pas un problème si grave que cela car les lignes les moins récemment utilisées ont toutes assez peu de chance d'être utilisées dans le futur. Entre choisir de remplacer une ligne qui a 0,5 % de chances d'être utilisée dans le futur et une autre qui a une chance de seulement 1 %, la différence est négligeable en termes de taux de succès. Mais les gains en termes de circuits ou de temps d'accès au cache de ces algorithmes sont très intéressants. L'algorithme le plus simple consiste à couper le cache (ou chaque voie s'il est associatif) en plusieurs sections. L'algorithme détermine la section la moins récemment utilisée, avant de choisir aléatoirement une ligne de cache dans cette section. Pour implémenter cet algorithme, il nous suffit d'un registre qui mémorise le morceau le moins récemment utilisé, et d'un circuit qui choisit aléatoirement une ligne de cache. Cette technique s'adapte particulièrement bien avec des caches associatifs à voies : il suffit d'utiliser autant de morceaux que de voies. Autre algorithme, un peu plus efficace : le '''pseudo-LRU de type M'''. Cet algorithme attribue un bit à chaque ligne de cache, bit qui sert à indiquer de façon approximative si la ligne de cache associée est une candidate pour un remplacement ou non. Il vaut 1 si la ligne n'est pas une candidate pour un remplacement et zéro sinon. Le bit est mis à 1 lorsque la ligne de cache associée est lue ou écrite. Évidemment, au fil du temps, toutes les lignes du cache finiront par avoir leur bit à 1. Lorsque cela arrive, l'algorithme remet tous les bits à zéro, sauf pour la dernière ligne de cache accédée. L'idée derrière cet algorithme est d'encercler la ligne de cache la moins récemment utilisée au fur et à mesure des accès. L'encerclement commence lorsque l'on remet tous les bits associés aux lignes de cache à 0, sauf pour la ligne accédée en dernier. Au fur et à mesure des accès, l'étau se resserre autour de la ligne de cache la moins récemment utilisée. Après un nombre suffisant d'accès, l'algorithme donne une estimation particulièrement fiable. Et comme les remplacements de lignes de cache sont rares comparés aux accès aux lignes, cet algorithme finit par donner une bonne estimation avant qu'on ait besoin d'effectuer un remplacement. Le dernier algorithme d'approximation, le '''PLURt''', se base sur ce qu'on appelle un arbre de décision. Il a besoin de n − 1 bits pour déterminer la ligne LRU. Ces bits doivent être organisés en arbre, comme illustré plus bas. Chacun de ces bits sert à dire : le LRU est à ma droite ou à ma gauche : il est à gauche si je vaux 0, et à droite si je vaux 1. Trouver le LRU se fait en traversant cet arbre, et en interprétant les bits un par un. Au fur et à mesure des lectures, les bits sont mis à jour dans cet arbre, et pointent plus ou moins bien sur le LRU. La mise à jour des bits s'effectue lors des lectures et écritures : quand une ligne est lue ou écrite, elle n'est pas la ligne LRU. Pour l'indiquer, les bits à 1 qui pointent vers la ligne de cache sont mis à 0 lors de la lecture ou écriture. {| |[[File:Organisation des bits avec l'algorithme PLURt.jpg|vignette|Organisation des bits avec l'algorithme PLURt.]] |[[File:Ligne de cache pointée par les bits de l'algorithme.png|vignette|Ligne de cache pointée par les bits de l'algorithme.]] |} ===LRU amélioré=== L'algorithme LRU, ainsi que ses variantes approximatives, sont très efficaces tant que le programme respecte relativement bien la localité temporelle. Par contre, Le LRU se comporte assez mal dans les circonstances ou la localité temporelle est mauvaise mais où la localité spatiale est respectée, le cas le plus emblématique étant le parcours d'un tableau. Pour résoudre ce problème, des variantes du LRU existent. Une variante très connue, l''''algorithme 2Q''', utilise deux caches : un cache FIFO pour les données accédées une seule fois et un second cache LRU. Évidemment, les données lues une seconde fois sont migrées du cache FIFO vers le cache LRU, ce qui n'est pas très pratique. Les processeurs n'utilisent donc pas cette technique, mais celle-ci est utilisée dans les caches de disque dur. D'autres variantes du LRU combinent plusieurs algorithmes à la fois et vont choisir lequel de ces algorithmes est le plus adapté à la situation. Notre cache pourra ainsi détecter s’il vaut mieux utiliser du MRU, du LRU, ou du LFU suivant la situation. ==Les écritures dans le cache : gestion et optimisations== Les écritures se font à une adresse mémoire bien précise, qui peut ou non être chargée dans le cache. Si la donnée à écrire est chargée dans le cache, elle est modifiée directement dans le cache, mais elle ne l'est pas forcément en mémoire RAM. Suivant le processeur, les écritures sont ou non propagées en mémoire RAM. Il existe deux stratégies d'écritures, appelées respectivement le ''write-back'' et le ''write-through''. Avec un cache ''write-back'', si la donnée à mettre à jour est présente dans le cache, on écrit dans celui-ci sans écrire dans la mémoire RAM. Dans ces conditions, une donnée n'est enregistrée en mémoire que si celle-ci quitte le cache, ce qui évite de nombreuses écritures mémoires inutiles. [[File:Cache write-through.png|centre|vignette|upright=2|Cache write-through.]] Avec les caches '''Write-Through''', toute écriture dans le cache est propagée en RAM. Cette stratégie augmente le nombre d'écritures dans la mémoire RAM, ce qui peut saturer le bus reliant le processeur à la mémoire. Les performances de ces caches sont donc légèrement moins bonnes que pour les caches ''write back''. Par contre, ils sont utiles dans les architectures avec plusieurs processeurs, comme nous le verrons dans les chapitres sur les architectures multiprocesseurs. [[File:Cache write-back.png|centre|vignette|upright=2|Cache write-back.]] ===Les caches ''Write-through''=== Sans optimisation particulière, on ne peut écrire dans un cache ''write-through'' pendant qu'une écriture en RAM a lieu en même temps : cela forcerait à effectuer deux écritures simultanées, en comptant celle imposée par l'écriture dans le cache. Pour éviter cela, certains caches ''write-through'' intègrent un '''tampon d’écriture''', qui sert de file d'attente pour les écritures en RAM. C'est une mémoire FIFO dans laquelle on place temporairement les données à écrire en RAM, où elles attendent en attendant que la RAM soit libre. Grâce à lui, le processeur peut écrire dans un cache même si d'autres écritures sont en attente dans le tampon d'écriture. Par souci d'efficacité, des écritures à la même adresse en attente dans le tampon d’écriture sont fusionnées en une seule. Cela fait un peu de place dans le tampon d’écriture, et lui permet d'accumuler plus d'écritures avant de devoir bloquer le cache. Il est aussi possible de fusionner des écritures à adresses consécutives de la mémoire en une seule écriture en rafales. Dans les deux cas, on parle de '''combinaison d'écriture'''. Mais la technique du tampon d'écriture a cependant un léger défaut qui se manifeste dans une situation bien précise : quand le processeur veut lire une donnée en attente dans le tampon d’écriture. La première manière de gérer cette situation est de mettre en attente la lecture tant que la donnée n'a pas été écrite en mémoire RAM. On peut aussi lire la donnée directement dans le tampon d'écriture, cette optimisation portant le nom de '''''store-to-load forwading'''''. Dans tous les cas, il faut détecter le cas où une lecture accède à une donnée dans le tampon d'écriture. À chaque lecture, l'adresse à lire est envoyée au tampon d'écriture, qui vérifie si une écriture en attente se fait à cette adresse. Pour cela, le tampon d’écriture doit être un cache, dont chaque entrée mémorise une écriture. Chaque ligne de cache contient la donnée à écrire, et le tag de la ligne de cache contient l'adresse où écrire la donnée. Notons que cache d'écriture a une politique de remplacement de type FIFO, le tampon d'écriture non-optimisé étant une mémoire FIFO. ===Les caches ''Write-back''=== Les caches ''write-back'' ont beau avoir des performances supérieures à celles des caches ''write-through'', il existe des optimisations qui permettent d'améliorer leurs performances. Ces optimisations consistent à ajouter des caches spécialisés à côté du cache proprement dit. Ces caches permettent de mémoriser des données qui sont éliminées du cache par les algorithmes de remplacement de ligne cache, sans pour autant faire une écriture en RAM. En suivant la procédure habituelle de remplacement des lignes de cache, on doit rapatrier la ligne en RAM avant d'en charger une nouvelle. On peut améliorer la situation en faisant l'inverse : on charge la nouvelle ligne pendant que l'ancienne donnée est rapatriée en RAM. Ainsi, la nouvelle ligne est disponible plus tôt pour le processeur, diminuant son temps d'attente. Pour implémenter cette technique, on doit mémoriser l'ancienne ligne de cache temporairement dans un '''cache d’éviction''' (ou ''write-back buffer''). [[File:Cache d’éviction.png|centre|vignette|upright=2|Cache d’éviction]] Les caches directement adressés ou associatifs par voie possèdent aussi un tampon d’écriture amélioré. Pour limiter les défauts par conflit de ces caches, des scientifiques ont eu l'idée d'insérer un cache pour stocker les données virées du cache. En faisant ainsi, si une donnée est virée du cache, on peut alors la retrouver dans ce cache spécialisé. Ce cache s'appelle le '''cache de victime'''. Ce cache de victime est géré par un algorithme de suppression des lignes de cache de type FIFO. Petit détail : ce cache utilise un tag légèrement plus long que celui du cache directement adressé au-dessus de lui. L'index de la ligne de cache doit en effet être contenu dans le tag du cache de victime, pour bien distinguer deux adresses différentes, qui iraient dans la même ligne du cache juste au-dessus. [[File:Victim Cache Implementation Example.svg|centre|vignette|upright=1|Cache de victime.]] ===La configuration du fonctionnement du cache=== Sur de nombreux processeurs, il est possible de configurer la mémoire cache pour qu'elle fonctionne soit en mode ''write-back'', soit en mode ''write-through''. Pour cela, les processeurs modernes incorporent des '''registres de configuration du cache'''. Le terme ''registre de configuration du cache'' est assez transparent et indique bien quel est leur rôle. Ils configurent comment le cache est utilisé et permettent notamment de configurer le cache pour dire s'il doit fonctionner en mode ''write-back'' ou ''write-through''. Ils permettent aussi d'activer ou de désactiver la combinaison sur écriture. Les registres en question sont configurés soit par le BIOS, soit par le système d'exploitation. Ce sont des registres protégés, que les applications ne peuvent pas configurer, elles n'en ont pas le droit. Typiquement, ils ne sont accessibles en écriture qu'en mode noyau. Sur les processeurs x86, les registres de configuration du cache sont appelés des '''''Memory type range registers''''' (''MTRRs''). Les MTRRs sont assez nombreux, et il y a notamment une différence entre mode réel et protégé. Si vous vous souvenez des chapitres sur le mode d'adressage et la mémoire virtuelle, vous vous souvenez que les processeurs x86 incorporent plusieurs modes de fonctionnement. En mode réel, le processeur ne peut adresser qu'un mébioctet de RAM, avec un système de segmentation particulier. En mode protégé, le processeur peut adresser toute la mémoire et la segmentation fonctionne différemment, quand elle n'est pas simplement désactivée. Les MTRRs sont séparés en deux : ceux pour le mode réel, ceux pour le mode protégé. Les MTRRs fixes sont ceux qui configurent le cache en mode réel, ils étaient utilisés pour gérer l'accès au BIOS, à la mémoire VGA de la carte graphique, et quelques autres accès aux entrées-sorties basiques gérées nativement par le BIOS. Pour le mode protégé, les processeurs au-delà du 386 incorporent des MTRRs variables, qui servent pour les autres entrées-sorties en général, notamment les périphériques PCI, la mémoire vidéo de la carte graphique, et j'en passe. De nos jours, les registres de configuration du cache sont désuets et cette fonctionnalité est gérée directement par la mémoire virtuelle. La table des pages contient, pour chaque page mémoire, des bits de contrôle qui disent si la page mémoire est cacheable ou non. Le contournement de cache est alors géré par le système de mémoire virtuelle, le cache de TLB et tout ce qui va avec. ===L’allocation sur écriture=== Que faire quand une écriture modifie une donnée qui n'est pas dans le cache ? Doit-on écrire la donnée dans le cache, ou non ? Si la donnée est écrite dans le cache, on dit que le cache fait une '''allocation sur l'écriture''' (ou ''write-allocate''). Certains caches effectuent une telle allocation sur écriture, mais d'autres ne le font pas ou du moins pas systématiquement. L’allocation sur écriture peut se décliner en deux sous-catégories : le '''chargement à la demande''' et l''''écriture immédiate'''. Dans le premier cas, on charge la donnée à modifier dans le cache, et on la remplace avec la donnée écrite. Dans l'écriture immédiate, l'écriture a lieu directement dans le cache et la donnée à modifier n'est pas chargée dans le cache. Évidemment, seule une portion de la ligne de cache contient la donnée écrite (valide), et le reste contient des données invalides. Le cache doit savoir quelles sont les portions du cache qui sont valides : cela demande d'utiliser un ''sector cache''. [[File:Write-back with write-allocation.svg|centre|vignette|upright=2|Cache Write-back avec allocation sur écriture.]] Sans allocation sur écriture, l'écriture est transférée directement aux niveaux de cache inférieurs ou à la mémoire si la donnée à modifier n'est pas dans le cache. Certains caches de ce genre utilisent une petite optimisation : lors de toute écriture, ils supposent que l'écriture donnera un succès de cache. Si c'est le cas, la ligne de cache qui contient la donnée est mise à jour avec la donnée à écrire. Mais si ce n'est pas le cas, la ligne de cache est invalidée, et l'écriture est transférée directement à la mémoire ou aux niveaux de cache inférieurs. [[File:Write-through with no-write-allocation.svg|centre|vignette|upright=2|Cache Write-through sans allocation sur écriture.]] ===La cohérence des caches=== Il arrive parfois que la mémoire d'un ordinateur soit mise à jour, sans que les modifications soient répercutées dans les mémoires cache. Dans ce cas, le cache contient une donnée périmée. Or, un processeur doit toujours éviter de se retrouver avec une donnée périmée et doit toujours avoir la valeur correcte dans ses caches : cela s'appelle la '''cohérence des caches'''. Il est possible de se retrouver avec des valeurs périmées dans le cache sur les ordinateurs avec plusieurs processeurs, ou si un périphérique écrit en RAM, les modifications ne sont pas répercutées automatiquement dans les mémoires cache. Pour résoudre ce problème, on peut interdire de charger dans le cache des données stockées dans les zones de la mémoire dédiées aux périphériques. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires cache. Autre solution : utiliser le fait que les périphériques déclenchent une interruption matérielle pour laisser le contrôleur DMA accéder à la mémoire. Dans ce cas, il suffit de vider les caches à chaque interruption matérielle. Le processeur peut le faire automatiquement, ou fournir des instructions pour. ==Le ''cache bypassing'' : contourner le cache== Dans certaines situations, le cache n'est pas utilisé pour certains accès mémoire. Diverses techniques permettent en effet d'effectuer des accès mémoire qui contournent le cache, qui ne passent pas par le cache. Ils sont utilisés quand l'accès en cache fait que des instructions normales ne fonctionnent pas. Par exemple, de tels accès directs à la RAM sont notamment utilisés pour l'implémentation d'instructions atomiques, une classe d'instructions spécifiques utilisées sur les processeurs multicœurs, dont nous parlerons dans plusieurs chapitres. Mais ils sont aussi utilisés pour l'accès aux périphériques, ce que nous allons voir maintenant. ===Accéder aux périphériques demande de contourner le cache=== Pour rappel, un périphérique (au sens d'entrée-sortie) contient des registres d’interfaçage qui ont une adresse au même titre que les cases mémoire. Un périphérique peut à tout instant modifier ses registres d’interfaçage, ce qui se répercute automatiquement dans l'espace d'adressage, mais rien de tout cela n'est transmis au cache. Si les accès aux périphériques passaient par l'intermédiaire du cache, on aurait droit à des problèmes. On aurait encore une fois droit à des problèmes de cohérence des caches. Le problème est géré différemment suivant que l'on utilise un espace d'adressage séparé ou des entrées-sorties mappées en mémoire. La solution est que les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache. Cela demande d'adapter le cache et le processeur. L'implémentation exacte dépend de comment sont adressés les périphériques. Pour rappel, il y a deux solutions pour adresser les périphériques : soit les périphériques disposent d'un espace d'adressage séparé de celui de la mémoire, soit il y un espace d'adressage unique partagé entre processeur et mémoire. Les deux cas donnent des solutions différentes. Avec un espace d'adressage séparé, l'espace d'adressage des périphériques n'est pas caché : aucun accès dans cet espace d'adressage ne passe par le cache. La mémoire cache n'est utilisée que pour l'espace d'adressage des mémoires, rien d'autre. C'est de loin le cas le plus simple : il suffit de concevoir le processeur pour. Il dispose d'instructions séparées pour les accès aux registres d’interfaçage et à la RAM/ROM, les premières ne passent pas par le cache, les autres si. Avec des entrées-sorties mappées en mémoire, la même solution est utilisée, mais dans une version un peu différente. Là encore, les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache, si on veut qu'ils marchent comme ils le doivent. Cela demande d'adapter le cache et le matériel pour que accès aux périphériques mappés en mémoire contournent le cache. Des adresses, voire des zones entières de la mémoire, sont marquées comme étant non-cachables. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires caches. Là encore, le processeur doit être prévu pour : on doit pouvoir le configurer de manière à marquer certaines zones de la RAM comme non-cacheable. Reste qu'il faut marquer des régions de la RAM comme non-cacheable. Pour cela, on améliore les registres de configuration du cache, vus plus haut, afin qu'ils permettent de configurer certaines portions de la RAM pour préciser qu'elles ne doivent pas être mises en cache, qu'il faut activer le contournement de cache pour celles-ci. ===Contourner le cache pour des raisons de performance=== Il arrive que des données avec une faible localité soient chargées dans le cache inutilement. Or, il vaut mieux que ces données transitent directement entre le processeur et la mémoire, sans passer par l'intermédiaire du cache. Pour cela, le processeur peut fournir des instructions d'accès mémoire qui ne passent pas par le cache, à côté d'instructions normales. De telle instructions sont appelées des '''instructions mémoire non-temporelles'''. Non-temporelle, dans le sens : pas de localité temporelle (c.a.d que les données ne seront pas réutilisées plus tard). Mais il existe aussi des techniques matérielles, où le cache détecte à l'exécution les lectures qui gagnent à contourner le cache. La dernière méthode demande d'identifier les instructions à l'origine des défauts de cache, le processeur accédant directement à la RAM quand une telle instruction est détectée. Si une instruction d'accès mémoire fait trop de défauts de cache, c'est signe qu'elle gagne à contourner le cache. L'idée est de mémoriser, pour chaque instruction d'accès mémoire, un historique de ses défauts de cache. Il existe plusieurs méthodes pour cela, mais toutes demandent d'ajouter de quoi mémoriser l'historique des défauts de cache des instructions. L'historique est mémorisé dans une mémoire appelée la '''table d’historique des défauts de lecture''' (''load miss history table''), qui est souvent un cache. L'historique en question est, dans sa version la plus simple, un compteur de quelques bits incrémenté à chaque succès de cache et décrémenté à chaque défaut de cache, qui indique si l'instruction a en moyenne fait plus de défauts ou de succès de cache. La table associe le ''program counter'' d'une instruction mémoire à cet historique. À la première exécution d'une instruction d'accès mémoire, une entrée de cette table est réservée pour l'instruction. Lors des accès ultérieurs, le processeur récupérer les informations associées et décide s'il faut contourner le cache ou non. ==La hiérarchie mémoire des caches== [[File:Cache Hierarchy.png|vignette|Hiérarchie de caches]] On pourrait croire qu'un seul cache est largement suffisant pour compenser la lenteur de la mémoire. Hélas, les processeurs sont devenus tellement rapides que les caches sont eux-mêmes très lents ! Pour rappel, plus une mémoire peut contenir de données, plus elle est lente. Et les caches ne sont pas épargnés. Si on devait utiliser un seul cache, celui-ci serait très gros et donc trop lent. La situation qu'on cherche à éviter avec la mémoire RAM revient de plus belle. Même problème, même solution : si on a décidé de diviser la mémoire principale en plusieurs mémoires de taille et de vitesse différentes, on peut bien faire la même chose avec la mémoire cache. Depuis environ une vingtaine d'années, un processeur contient plusieurs caches de capacités très différentes : les caches L1, L2 et parfois un cache L3. Certains de ces caches sont petits, mais très rapides : c'est ceux auxquels on va accéder en priorité. Viennent ensuite d'autres caches, de taille variable, mais plus lents. Les processeurs ont donc une hiérarchie de caches qui se fait de plus en plus complexe avec le temps. Cette hiérarchie est composée de plusieurs niveaux de cache, qui vont des niveaux inférieurs proches de la mémoire RAM à des niveaux supérieurs proches du processeur. Plus on monte vers les niveaux supérieurs, plus les caches sont petits et rapides. Un accès mémoire dans une hiérarchie de cache fonctionne comme suit : on commence par vérifier si la donnée recherchée est dans le cache le plus rapide, à savoir le cache L1. Si c'est le cas,n on la charge depuis ce cache directement. Si elle n’y est pas, on vérifie si elle est dans le cache de niveau supérieur, le cache L2. Et rebelote ! Si elle n'y est pas, on vérifie le cache du niveau supérieur. Et on répète cette opération, jusqu’à avoir vérifié tous les caches. Si la donnée n'est dans aucun cache, on doit alors aller chercher la donnée en mémoire. [[File:Hiérarchie de caches.png|centre|vignette|upright=2|Hiérarchie de caches]] Il y a des différences assez notables entre chaque niveau de cache. Par exemple, les différents niveaux de cache n'ont pas forcément les mêmes politiques de remplacement des lignes de cache. Le cache L1 a généralement une politique de remplacement simple, très rapide, mais peu efficace. De même, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Le cache le plus proche de la mémoire est appelé le '''cache de dernier niveau''', ''Last Level Cache'' en anglais. Il a parfois des caractéristiques totalement différentes des autres caches. Par exemple, sur les processeurs multicoeurs, le cache L3 n'est techniquement pas dans le cœur, mais fait partie d'un ensemble de circuits reliés, comme le contrôleur mémoire ou l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc. ===Les caches exclusifs et inclusifs=== Notons que du point de vue de cette vérification, il faut distinguer les caches inclusifs et exclusifs. Avec les caches inclusifs, si une donnée est présente dans un cache, alors elle est présente dans les caches des niveaux inférieurs, ce qui implique l'existence de données en doublon dans plusieurs niveaux de cache. À l'opposé, les caches exclusifs font que toute donnée est présente dans un seul cache, pas les autres. Il existe aussi des caches qui ne sont ni inclusifs, ni exclusifs. Sur ces caches, chaque niveau de cache gère lui-même ses données, sans se préoccuper du contenu des autres caches. Pas besoin de mettre à jour les niveaux de cache antérieurs en cas de mise à jour de son contenu, ou en cas d'éviction d'une ligne de cache. La conception de tels caches est bien plus simple. Dans les '''caches exclusifs''', le contenu d'un cache n'est pas recopié dans le cache de niveau inférieur. Il n'y a pas de donnée en double et on utilise 100 % de la capacité du cache, ce qui améliore le taux de succès. Par contre, le temps d'accès est un peu plus long. La raison est que si une donnée n'est pas dans le cache L1, on doit vérifier l'intégralité du cache L2, puis du cache L3. De plus, assurer qu'une donnée n'est présente que dans un seul cache nécessite aux différents niveaux de caches de communiquer entre eux pour garantir que l'on a pas de copies en trop d'une ligne de cache, ce qui peut prendre du temps. [[File:Caches exclusifs.png|centre|vignette|upright=2|Caches exclusifs]] Dans le cas des '''caches inclusifs''', le contenu d'un cache est recopié dans les caches de niveau inférieur. Par exemple, le cache L1 est recopié dans le cache L2 et éventuellement dans le cache L3. Ce genre de cache a un avantage : le temps d'accès à une donnée est plus faible. La raison est qu'il ne faut pas vérifier tout un cache, mais seulement la partie qui ne contient pas de donnée en doublon. Par exemple, si la donnée voulue n'est pas dans le cache L1, on n'est pas obligé de vérifier la partie du cache L2 qui contient la copie du L1. Ainsi, pas besoin de vérifier certaines portions du cache, ce qui est plus rapide et permet de simplifier les circuits de vérification. En contrepartie, l'inclusion fait que qu'une partie du cache contient des copies inutiles, comme si le cache était plus petit. De plus, maintenir l'inclusion est compliqué et demande des circuits en plus et/ou des échanges de données entre caches. [[File:Caches inclusifs.png|centre|vignette|upright=2|Caches inclusifs]] Maintenir l'inclusion demande de respecter des contraintes assez fortes, ce qui ne se fait pas facilement. Premièrement, toute donnée chargée dans un cache doit aussi l'être dans les caches de niveau inférieur. Ensuite, quand une donnée est présente dans un cache, elle doit être maintenue dans les niveaux de cache inférieurs. De plus, toute donnée effacée d'un cache doit être effacée des niveaux de cache supérieurs : si une donnée quitte le cache L2, elle doit être effacée du L1. Ces trois contraintes posent des problèmes si chaque cache décide du remplacement des lignes de cache en utilisant un algorithme comme LRU, LFU, MRU, ou autre, qui utilise l'historique des accès. En effet, dans ce cas, le cache décide de remplacer les lignes de cache selon l'historique des accès, historique qui varie suivant chaque niveau de cache. Par exemple, une donnée rarement utilisée dans le L2 peut parfaitement être très fréquemment utilisée dans le L1 : la donnée sera alors remplacée dans le L2, mais sera maintenue dans le L1. On observe aussi des problèmes quand il existe plusieurs caches à un seul niveau : chaque cache peut remplacer les lignes de cache d'une manière indépendante des autres caches du même niveau, donnant lieu au même type de problème. Pour maintenir l'inclusion, les caches doivent se transmettre des informations qui permettent de maintenir l'inclusion. Par exemple, les caches de niveaux inférieurs doivent prévenir les niveaux de cache supérieurs quand ils remplacent une ligne de cache. De plus, toute mise à jour dans un cache doit être répercutée dans les niveaux de cache inférieurs et/ou supérieurs. On doit donc transférer des informations de mise à jour entre les différents niveaux de cache. Généralement, le contenu des caches d'instruction n'est pas inclus dans les caches de niveau inférieurs, afin d'éviter que les instructions et les données se marchent sur les pieds. Enfin, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Dans ce cas, l'inclusion est plus difficile à maintenir, pour des raisons assez techniques. ===Les caches eDRAM, sur la carte mère et autres=== D'ordinaire, les mémoires caches sont intégrées au processeur, à savoir que cache et CPU sont dans le même circuit imprimé. Les caches sont donc fabriqués avec de la SRAM, seule forme de mémoire qu'on peut implémenter dans un circuit intégré. Intégrer tous les caches dans le processeur est une solution et efficace. Mais certains processeurs ont procédé autrement. [[File:Cache-on-a-stick module.jpg|vignette|Cache-on-a-stick module]] Des processeurs assez anciens incorporaient un cache L1 dans le processeur, mais plaçaient un cache L2 sur la carte mère. Le cache était clippé sur un connecteur sur la carte mère, un peu comme le sont les barrettes de mémoire. On parlait alors de '''''Cache on a stick''''' (COAST). On aurait pu s'attendre à ce que de tels caches soient en DRAM, vu qu'ils sont placés sur des barrettes de RAM, mais la ressemblance avec la mémoire RAM principale s'arrête là. Le cache était fabriqué en mémoire SRAM, même s'il est en théorie possible de faire de tels caches avec de la DRAM. Les premiers processeurs avec un cache faisaient ainsi, au début des années 90. Il a été introduiot sur les processeurs Motorola, et a été utilisé sur les IBM PC et les Macintosh de l'époque. Les ordinateurs Macintosh utilisaient de tels caches, pour la pluaprt des modèles. Pour ce qui est des PC, les premiers processeurs x86 faisaient pareil, notamment les processeurs Intel. Le 486, le Pentium et le Pentium 2 utilisaient des ''Cache on a stick''. L'avantage est que cela permettait de mettre plus de cache, à une époque où les circuits étaient limités en transistors. De plus, cela permettait au consommateur de choisir quelle quantité de cache il voulait, selon ses finances. Il était possible de laisser le processeur fonctionner soit sans mémoire cache, soit avec un cache de 256 Kibioctets, de 512 Kibioctets, etc. Il était possible d'upgrader le cache si besoin. Pour les CPU Intel, le cache était connecté sur le bus système, au même titre que la mémoire RAM et les entrées-sorties. Il faut dire que les processeurs de l'époque utilisaient un bus système et n'avaient pas de bus mémoire dédié. Mais en théorie, rien n’empêche de connecter le cache sur un bus mémoire dédié. Toujours est-il que les lectures et écritures étaient propagées à la fois dans le cache et la RAM. Les écritures se faisaient dans les deux, systématiquement dans la RAM, mais aussi dans le cache en cas de succès de cache. Les lectures étaient servies soit par le cache en cas de succès de cache, soit par la RAM en cas de défaut de cache. Si le cache répondait en premier, la transaction sur le bus se terminait précocement et l'accès en RAM était abandonné. [[File:Intel486 Иерархия памяти.png|centre|vignette|upright=2.5|Intel486 : le cache était connecté sur le bus système.]] À l'inverse, certains processeurs possédaient un cache fabriqué en mémoire DRAM, et plus précisément avec de la mémoire eDRAM. Le cache n'était pas intégré dans le même circuit imprimé que le processeur, mais profitait d'une architecture en ''chiplet''. Pour rappel, cela veut dire que le processeur est en réalité composé de plusieurs circuits intégré séparés, mais interconnectés et soudés sur un même PCB carré. Avec un cache en eDRAM, le cache avait son propre circuit intégré, séparé du circuit intégré du processeur ou du circuit intégré pour le contrôleur mémoire/IO. Un exemple est celui du cache des processeurs Intel de microarchitecture Broadwell, vus dans ce chapitre dans la section sur les caches splittés. Les tags étaient intégrés dans le circuit intégré du processeur, mais les données étaient mémorisées dans une puce d'eDRAM séparée. La puce eDRAM correspondait en réalité à une DRAM adressable qui servait de DRAM pour les données et mémorisaient les voies du cache. ==Les caches adressés par somme et hashés== Les caches adressés par somme sont optimisés pour incorporer certains calculs d'adresse directement dans le cache lui-même. Pour rappel, certains modes d'adressage impliquent un calcul d'adresse, qui ajoute une constante à une adresse de base. Généralement, l'adresse de base est l'adresse d'un tableau ou d'une structure, et la constante ajoutée indique la position de la donnée dans le tableau/la structure. Les caches hashés et les caches adressés par somme permettent de faire l'addition directement dans la mémoire cache. Voyons d'abord les caches hashés, avant de passer aux caches adressés par somme. Sur les '''caches hashés''', l'addition est remplacée par une autre opération, par exemple des opérations bit à bit du style XOR, AND ou OR, etc. Seulement, utiliser des opérations bit à bit pose un problème : il arrive que deux couples Adresse/décalage donnent le même résultat. Par exemple, le couple Adresse/décalage 11101111/0001 donnera la même adresse que le couple 11110000/0000. Dit autrement, deux adresses censées être différentes (après application du décalage) sont en réalité attribuées à la même ligne de cache. Il est toutefois possible de gérer ces situations, mais cela demande des astuces de haute volée pour faire fonctionner la mémoire cache correctement. Sur les '''caches adressés par somme''', le décodeur est modifié pour se passer de l'addition. Pour comprendre comment, il faut rappeler qu'un décodeur normal est composé de comparateurs, qui vérifient si l'entrée est égale à une constante bien précise. Sur un cache ordinaire, l'addition est faite séparément du décodage des adresses par le cache, dans l'unité de calcul ou dans l'unité de génération d'adresse. [[File:Non sum adressed cache.png|centre|vignette|upright=2|Cache normal.]] Mais les caches adressés par somme modifient le décodeur, qui est alors composé de comparateurs qui testent si la somme adresse + décalage est égale à une constante. [[File:Cache adressé par somme.png|centre|vignette|upright=2|Cache adressé par somme.]] Chaque circuit du décodeur fait le test suivant, avec K une constante qui dépend du circuit : : <math>A + B = K</math> Ce qui est équivalent à faire le test suivant : : <math>A + B - K = 0</math> En complément à deux, on a <math>- K = \overline{K} + 1</math>. En injectant dans l'équation précédente, on a : : <math>A + B + \overline{K} + 1 = 0</math> En réorganisant les termes, on a : : <math>A + B + \overline{K} = - 1</math> Il suffit d'utiliser un additionneur ''carry-save'' pour faire l'addition des trois termes. Rappelons qu'un tel additionneur fournit deux résultats en sortie : une somme calculée sans propager les retenues et les retenues en question. Notons que les retenues sont à décaler d'un cran, vu qu'elles sont censées s'appliquer à la colonne suivante. En notant la somme S et les retenues R, on a: : <math>S + (R << 1) = - 1 </math>, le décalage d'un cran à gauche étant noté <math><< 1</math>. Ensuite, -1 est codé avec un nombre dont tous les bits sont à 1 en complément à un/deux. : <math>S + (R << 1) = 111 \cdots 111111</math> [[File:Sum + retenue add.png|centre|vignette|upright=2|Sum + retenue add]] Un simple raisonnement nous permet de savoir si le résultat est bien -1, sans faire l'addition <math>S + (R << 1)</math>. En effet, on ne peut obtenir -1 que si la somme est l'inverse des retenues : un 0 dans le premier nombre correspond à un 1 dans l'autre, et réciproquement. En clair, on doit avoir <math>\overline{S} = R << 1</math>. Pour vérifier cela, il suffit de faire un simple XOR entre la somme et les retenues décalées d'un cran. On a alors : : <math>S \oplus (R << 1) = 111 \cdots 111111</math> La comparaison avec -1 se fait avec une porte ET à plusieurs entrées. En effet, la porte donnera un 1 seulement si tous les bits d'entrée sont à 1, ce qui est ce qu'on veut tester. Au final, l'additionneur pour l'addition adresse + décalage est remplacé par un additionneur carry-save suivi d'une couche de portes XOR et d'un comparateur avec une constante, ce qui économise de circuits et améliore les performances. [[File:Final circuit of sum addressed cache.png|centre|vignette|upright=2|Cache adressé par somme.]] En prenant en compte que la constante K est justement une constante, certaines entrées de l'additionneur carry-save sont toujours à 0 ou à 1, ce qui permet quelques simplifications à grand coup d’algèbre de Boole. Chaque additionneur complet qui compose l’additionneur carry-save est remplacée par des demi-additionneurs (ou par un circuit similaire). Autant dire que l'on gagne tout de même un petit peu en rapidité, en supprimant une couche de portes logiques. Le circuit de décodage économise aussi des portes logiques, ce qui est appréciable. ==Les caches à accès uniforme et non-uniforme== Intuitivement, le temps d'accès au cache est le même pour toutes les lignes de cache. Il s'agit de cache appelés '''caches à accès uniforme''', sous-entendu à temps d'accès uniforme. Mais sur les caches de grande capacité, il arrive souvent que le temps de propagation des signaux varie fortement suivant la ligne de cache à lire. D'ordinaire, on se cale sur la ligne de cache la plus lente pour caler la fréquence d'horloge du cache, même si on pourrait faire mieux. Cependant, les '''caches à accès non uniforme''' ont une latence différente pour chaque ligne d'un même cache. Certaines lignes de cache sont plus rapides que d'autres. Niveau terminologie, nous allons parler de caches UCA et NUCA : ''Uniform Access Cache'' pour les caches à accès uniforme, ''Non-Uniform Access Cache'' pour les caches à accès non-uniforme. [[File:Caches UCA et NUCA.png|vignette|Caches UCA et NUCA.]] Les caches NUCA et UCA sont souvent composés de plusieurs banques séparées, typiquement une par voie. Sur les caches UCA, les banques sont interconnectées avec le processeur de manière à ce que toutes les interconnexions ont la même longueur pour toutes les banques. Typiquement, les banques sont organisées en carré, avec les interconnexions qui partent du centre, avec une disposition en H, illustrée ci-contre Mais avec les caches NUCA, ce n'est pas le cas. Les interconnexions sont simplifiées et ont des longueurs différentes. Les caches NUCA n'ont pas tous le même genre d'interconnexions, qui dépendent du cache NUCA. En général, les interconnexion forme un réseau avec des sortes de routeurs qui redirigent les données/commandes vers la bonne destination : cache ou processeur. Les banques plus proches du processeur sont accessibles plus rapidement que celles éloignées, même si la différence n'est pas énorme. Les caches NUCA sont généralement associatifs par voie. Les plus simples utilisent une banque par voie pour le cache, ce qui fait que certaines voies répondent plus vite que les autres. La détection des succès de cache est alors plus rapide si la donnée lue/écrite est dans une voie/banque rapide. En théorie, les défauts de cache demandent de vérifier toutes les banques, et se calent donc sur la pire latence. Mais divers caches se débrouillent pour que ce ne soit pas le cas, soit en vérifiant les banquyes unes par une, soit par un mécanisme de recherche plus complexe. Les caches NUCA sont surtout utilisés pour les caches L3 et L4, éventuellement les caches L2. Les caches L1 sont systématiquement des caches UCA, car la latence de l'accès au cache L1 est utilisée par le processeur pour décider quand lancer les instructions. Pour simplifier, le processeur peut démarrer en avance une instruction avant qu'une opérande soit lue dans le cache L1, de manière à ce que la donnée arrive en entrée de l'ALU pile en même temps que l'instruction. Une histoire d'exécution dans le désordre et d'émission anticipée des instructions qu'on détaillera dans une bonne dizaine de chapitres. Toujours est-il que tout est plus simple pour le processeur si le cache L1 a un temps d'accès fixe. Par contre, les caches L3 et L4 sont traités en attendant que les données arrivent, le processeur reprend l'exécution des instructions quand les caches L3 et L4 ont terminé de répondre, pas avant. Avec l'association une banque = une voie, la correspondance ligne de cache → bloc de mémoire qui est statique : on ne peut pas déplacer le contenu d'une ligne de cache dans une autre portion de mémoire plus rapide suivant les besoins. Mais la recherche académique a étudié le cas où la correspondance entre une ligne de cache et une banque varie à l’exécution. Pour nommer cette distinction, on parle de caches S-NUCA (''Static NUCA'') et D-NUCA (''Dynamic NUCA''). Intuitivement, on s'attend à ce que les caches D-NUCA soient plus performants que les caches S-NUCA. Les lignes de cache les plus utilisées peuvent migrer dans une banque rapide, alors que les lignes de cache moins utilisées vont dans une banque éloignée. Les lignes de cache se répartissent dans le cache dynamiquement dans les banques où elles sont le plus adaptées. Mais paradoxalement, le gain des caches D-NUCA est presque nul, voire insignifiant. La raison est que les caches D-NUCA doivent incorporer un système pour déterminer dans quelle banque se situe la donnée pour détecter les succès/défauts de cache, ainsi qu'un système pour migrer les données entre banques. Et ce système augmente le temps d'accès au cache, réduisant à néant l'intérêt d'un cache D-NUCA. Si on économise quelques microsecondes de temps d'accès en passant d'un cache UCA à un cache S-NUCA, ce n'est pas pour les perdre en passant à un D-NUCA. La majorité des caches D-NUCA sont donc en cours de recherche, mais ne sont pas utilisés en pratique. ==La tolérance aux erreurs des caches== Une mémoire cache reste avant tout une mémoire RAM, bien que ce soit de la SRAM. Elle n'est pas parfaite et est donc sujette à des erreurs, qui peuvent inverser un bit ou l'effacer. De telles erreurs sont liées à des rayons cosmiques très énergétiques, à des particules alpha produites par le packaging ou le métal deu circuit intégré, peu importe : l'essentiel est qu'ils inversent parfois un bit. Les mémoires modernes savent se protéger contre de telles erreurs, en utilisant trois moyens. ===Les mémoires caches ECC et à bit de parité=== Le premier moyen est l'usage de codes correcteurs d'erreurs, qui ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Les bits ajoutés dépendent de la donnée mémorisée dans le byte, et servent à détecter une erreur, éventuellement à la corriger. Le cas le plus simple ajoute un simple bit de parité pour chaque byte et se contente de détecter les erreurs dans les corriger. Les autres codes ECC permettent eux de corriger des erreurs, mais ils demandent d'ajouter au moins deux bits par byte, ce qui a un cout en circuit plus élevé. Un simple bit de parité permet de détecter qu'un bit a été inversé, mais ne permet pas de corriger l'erreur. En soi, ce n'est pas un problème. Si une erreur est détectée, on considère que la ligne de cache est invalide. Le cache gère la situation comme un défaut de cache et va chercher la donnée valide en mémoire RAM. Le cout en circuits est donc faible, mais les défauts de cache sont plus nombreux. Les codes ECC sont eux capables de corriger les erreurs, si elles ne modifient pas trop de bits d'un coup. Par contre, ils utilisent deux à trois bits par octet, ce qui a un cout en circuits loin d'être négligeable. Il y a donc un compromis entre défauts de cache et cout en circuits. La gestion de l'ECC est différente suivant le niveau de cache. Généralement, le cache L1 n'utilise pas l'ECC mais se contente d'un simple bit de parité pour éviter la corruption de ses données. Le cache étant petit, les corruptions de données sont assez rares, et les défauts de cache induits faibles. Il est plus important d'utiliser un code de détection d'erreur simple, rapide, qui ne ralentit pas le cache et n'augmente pas sa latence. Si une ligne de cache est corrompue, il a juste à aller lire la ligne depuis le cache L2, ou un niveau de cache inférieur. Du moins, c'est possible sur le cache en question est un cache inclusif et/ou ''write-through''. Par contre, le niveau de cache L2 et ceux en-dessous utilisent presque systématiquement une mémoire SRAM ECC. La raison principale étant que ce sont des caches assez gros, pour lesquels la probabilité d'une erreur est assez élevée. Plus une mémoire a de bits et prend de la place, plus il y a une chance élevée qu'un bit s'inverse. Et vu que les caches L2/L3/L4 sont par nature plus lents et plus gros, ils peuvent se permettre le cout en performance lié à l'ECC, idem pour le cout en circuit. Sans compter qu'en cas d'erreur, ils doivent aller lire la ligne de cache originelle en mémoire RAM, ce qui est très lent ! Mieux vaut corriger l'erreur sur place en utilisant l'ECC. ===L'usage du ''memory scrubbing'' sur les caches=== La plupart des erreurs ne changent qu'un seul bit dans un byte, mais le problème est que ces erreurs s'accumulent. Entre deux accès à une ligne de cache, il se peut que plusieurs erreurs se soient accumulées, ce qui dépasse les capacités de correction de l'ECC. Dans ce cas, il existe une solution appelée le ''memory scrubbing'', qui permet de résoudre le problème au prix d'un certain cout en performance. Pour rappel, l'idée est de vérifier les lignes de caches régulièrement, pour éviter que les erreurs s'accumulent. Par exemple, on peut vérifier chaque ligne de cache toutes les N millisecondes, et corriger une éventuelle erreur lors de cette vérification. En faisant des vérifications régulières, on garantir que les erreurs n'ont pas le temps de s'accumuler, sauf en cas de malchance avec des erreurs très proches dans le temps. Il ne s'agit pas d'un rafraichissement mémoire, car les SRAM ne s'effacent pas), mais ça a un effet similaire. Et évidemment, le ''memory scrubbing'' a un cout en performance. On peut faire une comparaison avec le rafraichissement mémoire : les rafraichissement réguliers réduisent les performances, car cela fait des accès en plus. Des accès qui sont de plus timés à des instants bien précis qui ne sont pas forcément les plus adéquats. Il est possible qu'un rafraichissement ait lieu en même temps qu'un accès mémoire et le rafraichissement a la priorité, ce qui réduit les performances. La même chose arrive avec les vérifications du ''memory scrubbing''. Malgré tout, la technique a été utilisée sur les caches de certains processeurs commerciaux, dont des processeurs AMD Athlon et Athlon 64. Elle est surtout utilisable sur les caches L2/L3, pour lesquels le cout du pseudo-rafraichissement est acceptable. ==Un exemple de cache : le cache d'instruction== La grande majorité des processeurs utilise deux caches L1 séparés : un '''cache d'instructions''' dédié aux instructions, et un autre pour les données. Une telle organisation permet de charger une instruction tout en lisant une donnée en même temps. Notons que seul le cache L1 est ainsi séparé entre cache de données et d'instructions. Le cache d’instruction se situe en théorie entre l'unité de chargement et l'unité de décodage. En effet, ce cache prend en entrée une adresse et fournit une instruction. L'adresse est fournie par le ''program counter'', l'instruction est envoyée dans l'unité de décodage. Le cache se situe donc entre les deux. Le cache de données L1 est connecté au chemin de données, et notamment aux unités de communication avec la mémoire, pas au séquenceur. [[File:Caches L1 et positions dans le processeur.png|centre|vignette|upright=2.5|Caches L1 et positions dans le processeur]] Les deux caches sont reliés au processeur par des bus séparés, l'ensemble ressemble à une architecture Harvard, mais où les caches remplacent les mémoires RAM/ROM. Le cache d'instruction prend la place de la mémoire ROM et le cache de données prend la place de la mémoire RAM. Évidemment, il y a des niveaux de caches en dessous des caches de données/instruction, et ceux-ci contiennent à la fois données et instructions, les deux ne sont pas séparées dans des mémoires/caches séparés. Raison pour laquelle l'ensemble est appelé une '''architecture Harvard modifiée'''. Architecture Harvard, car l'accès aux données et instructions se font par des voies séparées pour le processeur, modifiée car la séparation n'est effective que pour le cache L1 et pas les autres niveaux de cache, et encore moins la RAM. Sur les processeurs modernes, il arrive très souvent que le processeur doive charger une instruction et lire/écrire une donnée en même temps. Et à vrai dire, c'est la règle plus que l'exception. L'usage d'une architecture Harvard modifiée permet cela très facilement : on peut accéder au cache d'instruction via un bus, et au cache de donnée avec l'autre ===Pourquoi scinder le cache L1 en cache d'instruction et de données=== L'usage d'un cache d’instruction séparé du cache de données est à contraster avec l'usage d'un cache L1 multiport unique, capable de mémoriser à la fois instructions et données. Les deux solutions sont possibles ont été utilisées. Les premiers processeurs avaient un cache L1 unique et multiport, mais ce n'est plus le cas sur les processeurs modernes, car les contraintes ne sont pas les mêmes. Le compromis à faire est celui entre deux petits caches rapides et un gros cache plus lent. Pour rappel, plus un cache est petit, plus il est rapide et chauffe moins. Donc au lieu d'utiliser, par exemple, un gros cache lent de 64 Kibioctets, on utilise deux caches de 32 kibioctets, plus rapides. La capacité totale est la même, mais le temps d'accès plus faible. En termes de temps d'accès, la meilleure solution est celle des deux caches simple port. Mais pour ce qui est de l'économie de circuits, c'est moins évident. Entre deux mémoires simple port et une mémoire multiport, la différence en termes de transistors est ambigüe et dépend de la capacité des caches. La différence est surtout notable pour les gros caches, moins pour les petits caches. Il faut aussi tenir compte de la capacité effective. Avec deux caches séparés, la répartition de la capacité du cache L1 est fixée une bonne fois pour toutes. Par exemple, avec un cache d'instruction de 32 KB et un cache de données de 32 KB, impossible d'allouer 40 KB aux données et 20 aux instructions. Alors qu'avec un cache L1 unique de 64 KB, on pourrait le faire sans soucis. La répartition se fait naturellement, en fonction de la politique de remplacement du cache et est proche de l'optimal. C'est là un désavantage des caches d'instructions/données séparés : une capacité effective moindre. Tout cela explique pourquoi le cache L1 est le seul à être ainsi scindé en deux, avec une séparation entre instructions et données : les contraintes au niveau du cache L1 et L2 ne sont pas les mêmes. Pour les caches L1, le temps d'accès est plus important que la capacité, ce qui favorise les caches séparés. Par contre, pour les caches L2/L3/L4, le temps d'accès n'est pas déterminant, alors que la capacité effective et l'économie en circuits sont significatives. ===La connexion des caches L1 avec le cache L2=== Pour les connexions avec le cache L2, tout dépend du processeur. Certains utilisent un cache L2 multiport, qui permet aux deux caches L1 de lire ou écrire dans le cache L2 simultanément. [[File:Cache d'instructions.png|centre|vignette|upright=1.5|Cache d'instructions.]] Si le cache L2 ne gère pas les accès simultanés, il n'y a qu'un seul bus relié aux caches L1 et au cache L2. On doit effectuer un arbitrage pour décider quel cache a la priorité, chose qui est réalisé par un circuit d'arbitrage spécialisé. [[File:Circuit d'arbitrage du cache.png|centre|vignette|upright=1.5|Circuit d'arbitrage du cache.]] Généralement, les caches d'instructions peuvent se permettre d'être plus petits que les caches de données, car les programmes sont souvent plus petits que les données manipulées. Songez que des programmes de quelques mébioctets peuvent parfois remplir la RAM avec plusieurs gibioctets de données. Lancez votre navigateur internet et ouvrez une page web un peu chargée, pour vous en convaincre ! ===Les spécificités du cache d'instruction : lecture seule, bloquant, etc=== Les instructions sont rarement modifiées ou accédées en écritures, contrairement aux données. Et cela permet d'utiliser un cache simplifié pour les instructions. Autant un cache généraliste doit permettre les lectures et écritures depuis le processeur (avec les échanges avec la RAM), autant un cache d'instruction peut se contenter des lectures provenant du CPU et des échanges avec la RAM. Le cache d'instructions est donc très souvent en « lecture seule » : le processeur ne peut pas écrire dedans, mais juste le lire ou charger des instructions dedans. Un cache d'instruction est donc plus simple qu'un cache pour les données : on peut retirer les circuits en charge de l'écriture (mais on doit laisser un port d'écriture pour charger les instructions dedans). Le gain en circuits permet d'utiliser un cache d'instruction plus gros ou au contraire de laisser de la place pour le cache de données. Le gain en termes de capacité compense alors un peu les inconvénients des caches séparés. Par contre, cela complique la gestion du code automodifiant, c'est-à-dire des programmes dont certaines instructions vont aller en modifier d'autres, ce qui sert pour faire de l'optimisation ou est utilisé pour compresser ou cacher un programme (les virus informatiques utilisent beaucoup de genre de procédés). Quand le processeur exécute ce genre de code, il ne peut pas écrire dans ce cache L1 d'instructions, mais doit écrire dans le cache L2 ou en RAM, avant de recharger les instructions modifiées dans le cache L1. Cela qui prend du temps et peut parfois donner lieu à des erreurs si le cache L1 n'est pas mis à jour. Les algorithmes de remplacement des lignes de cache optimaux pour les données ne le sont pas pour les instructions, de même que la taille optimale du cache, la taille des lignes de cache optimale, ou même les algorithmes de préchargement. Par exemple, pour le remplacement des lignes de cache, un simple algorithme LRU est presque optimal pour les instructions, autant il peut donner de mauvaises performances quand on manipule beaucoup de tableaux. Cela justifie d'utiliser des caches spécialisés pour chacune. On peut adapter le cache d'instruction à son contenu, ce qui le rend plus rapide ou plus petit à performance égale. Les caches d'instructions sont généralement des caches bloquants. Il ne servirait à rien de rendre un cache d'instruction non-bloquant, le cout en circuits ne se traduirait pas par une augmentation significative des performances. À l'opposé, les caches de données sont non-bloquants sur les architectures modernes, pour des raisons de performance. Ce qui rend la séparation assez intéressante, les deux caches ayant des besoins différents et des implémentations différentes, cela permet d'optimiser le cout en transistors des caches. ===L'impact du cache d'instruction sur les performances=== Sur les architectures conventionnelles, le cache d'instruction a plus d'impact sur les performances que le cache de données. La raison principale est que les instructions ont une meilleure localité spatiale et temporelle que pour les données. Pour la localité spatiale, les instructions consécutives se suivent en mémoire, alors que rien ne garantit que des données utilisées ensemble soient regroupées en mémoire. Pour localité temporelle, elle est très variable pour les données, mais très courante pour les instructions du fait de l'usage fréquent des boucles et des fonctions. : La présence de branchements atténue la localité temporelle des instruction, sauf que la majorité des branchements sautent à un endroit très proche, seuls les appels de fonction brisent la localité spatiale. La conséquence est qu'il arrive que certains CPU aient un cache L1 d'instruction plus gros que celui pour les données. On parle alors de '''cache L1 asymétriques'''. Un exemple est celui des processeurs AMD de microarchitecture Zen, dont le cache d'instruction était deux fois plus gros que le cache de données. Leur cache d'instruction faisait 64 kibioctets, contre seulement 32 pour le cache de données. D'ailleurs, il existe des processeurs assez extrêmes qui se contentent d'un cache d'instruction unique, sans cache de données. C'est le cas sur les processeurs vectoriels ou les GPU que nous verrons dans les chapitres de fin de ce wikilivres. De tels processeurs sont spécialisés dans la manipulation de tableaux de données, traitement qui a une faible localité temporelle. En conséquence, utiliser un cache de données n'est pas vraiment utile, voire peu être contreproductif, alors qu'un cache d’instruction fonctionne parfaitement. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Compléments sur les mémoires de masse | prevText=Compléments sur les mémoires de masse | next=Le préchargement | nextText=Le préchargement }} </noinclude> pdgsmjmrd6wn3lqhvqmeij5nuik6g3m 765125 765124 2026-04-26T15:48:15Z Mewtow 31375 /* L'exemple des processeurs 386 et du contrôleur de cache 82385 */ 765125 wikitext text/x-wiki Le cache est une mémoire intercalée entre la mémoire et un processeur, plus rarement à l'intérieur d'un périphérique. Il est souvent fabriquée avec de la mémoire SRAM, parfois avec de l'eDRAM. Sans lui, on se croirait à l'âge de pierre tellement nos PC seraient lents ! En effet, la mémoire est très lente comparée au processeur. Le temps mis pour accéder à la mémoire est du temps durant lequel le processeur n'exécute pas d'instruction (sauf cas particuliers impliquant un pipeline). Pour diminuer ce temps d'attente, il a été décidé d'intercaler une mémoire petite mais rapide, entre le processeur et la mémoire. Ainsi, le processeur accède à un cache très rapide plutôt qu'à une RAM beaucoup plus lente. ==L'accès au cache== Le cache contient une copie de certaines données présentes en RAM. La copie présente dans le cache est accessible bien plus rapidement que celle en RAM, vu que le cache est plus rapide. Mais seule une petite partie de ces données sont copiées dans le cache, les autres données devant être lues ou écrites dans la RAM. Toujours est-il que le cache contient une copie des dernières données accédées par le processeur. Une donnée est copiée dans la mémoire cache quand elle est lue ou écrite par le processeur. Le processeur conserve une copie de la donnée dans le cache après son premier accès. Les lectures/écritures suivantes se feront alors directement dans le cache. Évidemment, au fur et à mesure des accès, certaines données anciennes sont éliminées du cache pour faire de la place aux nouveaux entrants, comme nous le verrons plus tard. [[File:Principe d'une mémoire cache.gif|centre|vignette|upright=2|Principe d'une mémoire cache.]] La mémoire cache est invisible pour le programmeur, qui ne peut pas déceler celles-ci dans l'assembleur. Les accès mémoire se font de la même manière avec ou sans le cache. La raison à cela est que le cache intercepte les accès mémoire et y répond s'il en a la capacité. Par exemple, si le cache intercepte une lecture à une adresse et que le contenu de cette adresse est dans le cache, le cache va outrepasser la mémoire RAM et la donnée sera envoyée par le cache au lieu d'être lue en RAM. par contre, si un accès se fait à une adresse pour laquelle le cache n'a pas la donnée, alors l'accès mémoire sera effectué par la RAM de la même manière que si le cache n'était pas là. [[File:Accès au cache.png|centre|vignette|upright=2|Accès au cache]] ===Les succès et défauts de caches=== Tout accès mémoire est intercepté par le cache, qui vérifie si la donnée demandée est présente ou non dans le cache. Si la donnée voulue est présente dans le cache, on a un '''succès de cache''' (''cache hit'') et on accède à la donnée depuis le cache. Sinon, c'est un '''défaut de cache''' (''cache miss'') et on est obligé d’accéder à la RAM. Les défauts de cache peuvent avoir plusieurs origines. Tout ce qu'il faut savoir est que lorsque le processeur accède à une donnée ou une instruction pour la première fois, il la place dans la mémoire cache car elle a de bonnes chances d'être réutilisée prochainement. La raison à cela est qu'un programme a tendance à réutiliser les instructions et données qui ont été accédées dans le passé : c'est le ''principe de localité temporelle''. Bien évidement, cela dépend du programme, de la façon dont celui-ci est programmé et accède à ses données et du traitement qu'il fait, mais c'est souvent vrai en général. La première cause des défauts de cache est liée à la taille du cache. À force de charger des données/instructions dans le cache, le cache fini par être trop petit pour conserver les anciennes données. Le cache doit bien finir par faire de la place en supprimant les anciennes données, qui ont peu de chances d'être réutilisées. Ces anciennes données éliminées du cache peuvent cependant être accédées plus tard. Tout prochain accès à cette donnée mènera à un cache miss. C'est ce qu'on appelle un ''Capacity Cache Miss'', ou encore '''défaut de capacité'''. Les seules solutions pour éviter cela consistent à augmenter la taille du cache ou à optimiser le programme exécuté (voir plus bas). Une autre raison pour un défaut est donc la suivante. Lorsqu'on exécute à une instruction ou qu'on accède à donnée pour la première fois, celle-ci n'a pas encore été chargée dans le cache. Le défaut de cache est inévitable : ce genre de cache miss s'appelle un ''Cold Miss'', ou encore un '''défaut à froid'''. De tels défauts sont presque impossibles à éliminer, sauf à utiliser des techniques de préchargement qui chargent à l'avance des données potentiellement utiles. Ces méthodes de préchargement se basent sur le principe de localité spatiale, à savoir le fait que les programmes ont tendance à accéder à des données proches en mémoire. Pour donner un exemple, les instructions d'un programme sont placées en mémoire dans l’ordre dans lequel on les exécute : la prochaine instruction à exécuter est souvent placée juste après l'instruction en cours (sauf avec les branchements). Quand on accède à une donnée ou une instruction, le cache peut précharger les données adjacentes pour en profiter. Nous parlerons de ces techniques de préchargement dans un chapitre dédié, vers la fin du cours. ===Le fonctionnement du cache, vu du processeur=== Vu du processeur, le cache prend en entrée toutes les informations nécessaires pour effectuer un accès mémoire : des signaux de commande, une adresse et la donnée à écrire si besoin. Tout cela est passé en entrée du cache, celui-ci répondant aux accès mémoire via divers bits de contrôles, que le processeur peut lire à souhait. Le cache fournit aussi la donnée à lire, pour les lectures, sur une sortie, connectée directement au bus mémoire/processeur. Globalement, le cache a une capacité limitée, mais il prend en entrée des adresses complètes. Par exemple, sur un processeur 64 bits, le cache prend en entrée des adresses de 64 bits (sauf si optimisations), même si le cache en question ne fait que quelques mébioctets. Les caches sont souvent des mémoires multiports, surtout sur les processeurs récents. Les caches simple port sont rares, mêmes s'ils existent et ont existé par le passé. les caches double port sont eux plus fréquents, et ont généralement un port d'écriture séparé du port de lecture. Mais les caches récents ont plusieurs ports de lecture/écriture et sont capables de gérer plusieurs accès mémoire simultanés. Les données présentes dans le cache sont (pré)chargées depuis la mémoire, ce qui fait que toute donnée dans le cache est la copie d'une donnée en mémoire RAM. Le cache doit faire la correspondance entre une donnée du cache et l'adresse mémoire correspondante. Du point de vue du fonctionnement, on peut voir le cache comme une sorte de table de correspondance, qui mémorise des données, chacune étant associée à son adresse mémoire. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Cela vaut du point de vue du processeur, le fonctionnement interne du cache étant quelque peu différent selon le cache. Il existe des caches dont le fonctionnement interne est bien celui d'une table de correspondance matérielle, d'autres qui sont beaucoup plus optimisés. [[File:Fonctionnement d'une mémoire associative à correspondance.png|centre|vignette|upright=2|Fonctionnement simplifié d'une mémoire cache : les adresses sont dans la colonne de gauche, les données sont dans la colonne de droite. On voit qu'on envoie l'adresse au cache, que celui-ci répond en renvoyant la donnée associée.]] ==La performance des mémoires caches== L'analyse de la performance des mémoires caches est plus riche pour celle des autres mémoires. Sa performance dépend de beaucoup de paramètres, mais on peut cependant citer les principaux. Les deux premiers sont tout bonnement sa latence et son débit, comme pour n'importe quelle autre mémoire. La latence est plus importante que son débit, car le processeur est généralement plus rapide que le cache et qu'il n'aime pas attendre. Mais le critère le plus important pour un cache est sa capacité à empêcher des accès mémoire, son efficacité. Plus les accès mémoire sont servis par le cache au lieu de la RAM, meilleures seront les performances. Pour résumer, la performance d'un cache est surtout caractérisée par deux métriques : le taux de défaut, qui correspond à l’efficacité du cache, et la latence du cache. ===Le taux de succès/défaut=== Le '''taux de succès''' (hit ratio) est un premier indicateur des performances du cache, mais un indicateur assez imparfait. C'est le pourcentage d'accès mémoire qui ne déclenchent pas de défaut de cache. Plus il est élevé, plus le processeur accède au cache à la place de la RAM et plus le cache est efficace. Certains chercheurs préfèrent utiliser le '''taux de défauts''', à savoir le pourcentage d'accès mémoire qui entraînent un défaut de cache. Plus il est bas, meilleures sont les performances. Le taux de défaut est relié au taux de succès par l'équation <math>T_\text{succes} = 1 - T_\text{defaut}</math>. Par définition, il est égal à : : <math>\text{Taux de défauts de cache} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d’accès mémoires}}</math> Plutôt que de comparer le nombre de défauts/succès de cache au nombre d'accès mémoire, il est aussi possible de diviser le nombre de défauts par le nombre total d'instructions. On obtient alors le '''taux de défauts/succès par instruction''', une autre métrique utile. Par définition, elle est égale à : : <math>\text{Taux de défauts par instruction} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d'instructions}} = \text{Taux de défauts de cache} \times \frac{\text{Nombre d’accès mémoires}}{\text{Nombre d'instructions}}</math> Si certains défauts de cache sont inévitables quel que soit le cache, comme les défauts à froids, mentionnés plus haut, d'autres défauts peuvent être évités en augmentant la capacité du cache. C'est le cas des défauts de capacité qui sont causés par un accès à une donnée qui a été éliminée du cache faute de place. Plus le cache est gros, moins il a de chances d'être rempli, moins il doit rapatrier de données, plus son taux de succès augmente. Mais nous reviendrons sur le lien entre taille du cache et taux de défaut plus bas. Le taux de succès ne dépend pas que du cache, mais aussi de la conception des programmes exécutés. Une bonne utilisation du cache (ainsi que de la mémoire virtuelle) repose sur le programmeur qui doit prendre en compte les principes de localités dès la conception de ses programmes. Par exemple, un programmeur peut parfaitement tenir compte du cache au niveau de son algorithme : on peut citer l'existence des algorithmes ''cache oblivious'', qui sont conçus pour être optimaux quelle que soit la taille du cache. Le programmeur peut aussi choisir ses structures de données de manière à améliorer la localité. Par exemple, un tableau est une structure de donnée respectant le principe de localité spatiale, tandis qu'une liste chaînée ou un arbre n'en sont pas (bien qu'on puisse les implémenter de façon à limiter la casse). D'autres optimisations sont parfois possibles : par exemple, le sens de parcours d'un tableau multidimensionnel peut faire une grosse différence. Cela permet des gains très intéressants pouvant se mesurer avec des nombres à deux ou trois chiffres. Je vous recommande, si vous êtes programmeur, de vous renseigner le plus possible sur les optimisations de code ou algorithmiques qui concernent le cache : il vous suffira de chercher sur Google. Il y a une citation qui résume bien cela, prononcée par un certain Terje Mathisen. Si vous ne le connaissez pas, cet homme est un vieux programmeur (du temps durant lequel on codait encore en assembleur), grand gourou de l’optimisation, qui a notamment travaillé sur le moteur de Quake 3 Arena. {{BlocCitation|Almost all programming can be viewed as an exercise in caching.|auteur=Terje Mathisen}} ===La latence moyenne d'un cache=== Le temps mis pour lire ou écrire une donnée varie en présence d'un cache. Certaines lectures/écritures vont atterrir directement dans le cache (succès) tandis que d'autres devront aller chercher leur contenu en mémoire RAM (défaut de cache). Dans tous les cas, qu'il y ait défaut ou non, le cache sera consulté et mettra un certain temps à répondre, égal au temps de latence du cache. Tous les accès mémoires auront donc une durée au moins égale au temps de latence du cache, qui sera notée <math>T_c</math>. En cas de succès, le cache aura effectué la lecture ou l'écriture, et aucune action supplémentaire n'est requise. Ce qui n'est pas le cas en cas de défaut : le processeur devra aller lire/écrire la donnée en RAM, ce qui prend un temps supplémentaire égal au temps de latence de la mémoire RAM. Un défaut ajoute donc un temps, une pénalité, à l'accès mémoire. Dans ce qui suivra, le temps d'accès à la RAM sera noté <math>T_m</math>. Fort de ces informations, nous pouvons calculer le temps de latence moyen d'un accès mémoire, qui est la somme du temps d'accès au cache (pour tous les accès mémoire), multiplié par le temps lié aux défauts. On a alors : : <math>T = T_c + \text{Taux de défaut} \times T_m</math> On voit que plus le taux de succès est élevé, plus le temps de latence moyen sera bas, et inversement. Ce qui explique l'influence du taux de succès sur les performances du cache, influence assez importante sur les processeurs actuels. De nos jours, le temps que passe le processeur dans les défauts de cache devient de plus en plus un problème au fil du temps, et gérer correctement le cache est une nécessité, particulièrement sur les processeurs multi-cœurs. Il faut dire que la différence de vitesse entre processeur et mémoire est tellement importante que les défauts de cache sont très lents : alors qu'un succès de cache va prendre entre 1 et 5 cycles d'horloge, un cache miss fera plus dans les 400-1000 cycles d'horloge. Tout ce temps sera du temps de perdu que le processeur aura du mal à mitiger. Autant dire que réduire les défauts de cache est beaucoup plus efficace que d'optimiser les calculs effectués par le processeur (erreur courante chez de nombreux programmeurs, notamment débutants). ===L'impact de la taille du cache sur le taux de défaut et la latence=== Il y a un lien entre taille du cache, taux de défaut, débit binaire et latence moyenne. Globalement, plus un cache est gros, plus il est lent. Simple application de la notion de hiérarchie mémoire vue il y a quelques chapitres. Les raisons à cela sont nombreuses, mais nous ne pouvons pas les aborder ici, car il faudrait que nous sachions comment fonctionne un cache et ce qu'il y a à l'intérieur, ce qui sera vu dans la suite du chapitre. Toujours est-il que la latence moyenne d'un cache assez gros est assez importante. De même, le débit binaire d'un cache diminue avec sa taille, mais dans une moindre mesure. Les petits caches ont donc un gros débit binaire et une faible latence, alors que c'est l'inverse pour les gros caches. Une grande capacité de cache améliore le taux de succès, mais cela se fait au détriment de son temps de latence et de son débit, ce qui fait qu'il y a un compromis assez difficile à trouver entre taille du cache, latence et débit. Il peut arriver qu'augmenter la taille du cache augmente son temps d'accès au point d’entraîner une baisse de performance. Par exemple, les processeurs Nehalem d'Intel ont vus leurs performances dans certains jeux vidéos baisser de 2 à 3 %, malgré de nombreuses améliorations architecturales, parce que la latence du cache L1 avait augmentée de 2 cycles d'horloge. Pour avoir une petite idée du compromis à faire, regardons la relation entre taille du cache et taux de défaut. Il existe une relation approximative entre ces deux variables, appelée la '''loi de puissance des défauts de cache'''. Elle donne le nombre total de défaut de cache en fonction de la taille du cache et de deux autres paramètres. Voici cette loi : : <math>\text{Taux de défauts de cache} \approx K \times \text{Taille du cache}^{- \alpha }</math>, avec <math>K</math> et <math>\alpha</math> deux coefficients qui dépendent du programme exécuté. Le coefficient <math>\alpha</math> est généralement compris entre 0.3 et 0.7, guère plus, et varie suivant le programme exécuté. Précisons que cette loi ne marche que si le cache est assez petit par rapport aux données à utiliser. Pour un cache assez gros et des données très petites, la relation précédente est mise en défaut. Pour s'en rendre compte, il suffit d'étudier le cas extrême où toutes les données nécessaires tiennent dans le cache. Dans ce cas, il n'y a qu'un nombre fixe de défauts de cache : autant qu'il faut charger de données dans le cache. Le nombre de défauts de cache observé dans cette situation n'est autre que le coefficient <math>K</math> de la situation précédente, mais il n'y a aucune dépendance entre taux de défaut et taille du cache. L'origine de cette relation s'explique quand on regarde combien de fois chaque donnée est réutilisée lors de l’exécution d'un programme. La plupart des données finissent par être ré-accédées à un moment ou un autre et il se passe un certain temps entre deux accès à une même donnée. Sur la plupart des programmes, les observations montrent que beaucoup de réutilisations de données se font après un temps très court et qu'inversement, peu de ré-accès se font après un temps inter-accès long. Si on compte le nombre de réutilisation qui ont un temps inter-accès bien précis, on retrouve une loi de puissance identique à celle vue précédemment : : <math>\text{Nombre de réaccès avec un temps inter-accès égal à t} \approx K \times t^{- \beta}</math>, avec t le temps moyen entre deux réutilisations. Le coefficient <math>\beta</math> est ici compris entre 1.7 et 1.3. De manière générale, les coefficients <math>\alpha</math> et <math>\beta</math> sont reliés par la relation <math>\alpha = 1 - \beta</math>, ce qui montre qu'il y a un lien entre les deux relations. Précisons cependant que la loi de puissance précédente ne vaut pas pour tous les programmes informatiques, mais seulement pour la plupart d’entre eux. Il n'est pas rare de trouver quelques programmes pour lesquels les accès aux données sont relativement prédictibles et où une bonne optimisation du code fait que la loi de puissance précédente n'est pas valide. La loi de puissance des défauts de cache peut se démontrer à partir de la relation précédente, sous certaines hypothèses. Si un suppose que le cache est assez petit par rapport aux données, alors les deux relations sont équivalentes. L'idée qui se cache derrière la démonstration est que si le temps entre deux accès à une donnée est trop long, alors la donnée accédée aura plus de chance d'être rapatriée en RAM, ce qui cause un défaut de cache. La chance de rapatriement dépend de la taille du cache, un cache plus gros peut conserver plus de données et a donc un temps avant rapatriement plus long. ==Les lignes de cache et leurs tags== Du point de vue du processeur, les lectures et écritures se font mot mémoire par mot mémoire. Un processeur avec des entiers de 64 bits recoit des données de 64 bits de la part du cache, et y écrit des mots de 64 bits. Mais quand on regarde comment sont stockées les données à l'intérieur du cache, les choses sont différentes. ===Les lignes de cache=== Les données sont mémorisées dans le cache par blocs de plusieurs bytes, d'environ 64 à 256 octets chacun, qui portent le nom de '''lignes de cache'''. Les lignes de cache sont l'unité de stockage que l'on trouve à l'intérieur du cache, mais elles servent aussi d'unité de transaction avec la mémoire RAM. Sur les caches actuels, on transfère les données entre le cache et la RAM ligne de cache par ligne de cache, dans la limite de la taille du bus mémoire. Mais d'autres caches plus anciens permettaient de faire des transferts plus fins. C’est-à-dire qu'on pouvait mettre à jour quelques octets dans une ligne de cache sans avoir à la recopier intégralement depuis ou dans la mémoire RAM. En théorie, on pourrait imaginer des caches où les données sont stockées différemment, où l'unité serait le mot mémoire, par exemple. Par exemple, sur un processeur 64 bits, on aurait une ligne de cache de 64 bits. Cela aurait l'avantage de la simplicité : les transferts entre le processeur et la mémoire serait de même taille, l'intérieur du cache ressemblerait à son interface montrée au processeur. Mais cela aurait quelques défauts qui sont compensés par l'organisation en lignes de cache de grande taille. Le premier avantage des lignes de cache est lié à la localité spatiale, la tendance qu'on les programmes à accéder à des données proches les unes des autres. Des accès mémoires consécutifs ont tendance à se faire à des adresses proches, qui ont de bonnes chances d'être dans la même ligne de cache. Et des accès consécutifs à une même ligne de cache sont plus rapides que des accès à deux lignes distinctes. Une autre raison est tout simplement que cela simplifie considérablement la circuiterie du cache. Pour une capacité identique, il vaut mieux avoir peu de lignes de cache assez grosses, que beaucoup de petites lignes de cache. La raison est que les circuits du cache, comme le décodeur, l'encodeur et autres, ont moins de sorties et sont donc plus simples. ===L'alignement des lignes de cache=== Les lignes de cache sont des blocs de plusieurs dizaines à centaines de bytes, dont la taille est presque toujours une puissance de deux. De plus, les lignes de cache sont alignées en mémoire. Nous avions déjà abordé la notion d'alignement mémoire dans un chapitre précédent, mais le concept d'alignement des lignes de cache est quelque peu différent. Quand nous avions parlé d'alignement auparavant, il s'agissait de l'alignement des données manipulées par le processeur, qui faisait partie du jeu d'instruction du processeur. Ici, nous parlons d'un alignement totalement différent, invisible pour le programmeur, sans lien avec le jeu d’instruction. Voyons de quoi il retourne. Concrètement, cela veut dire que du point de vue du cache, la RAM est découpée en blocs qui font la même taille qu'une ligne de cache, aux positions prédéterminées, sans recouvrement entre les blocs. Par exemple, pour un cache dont les lignes de cache font 256 octets, le premier bloc est à l'adresse 0, le second est 256 octets plus loin, c'est à dire à l'adresse 256, le troisième à l'adresse 512, la quatrième à l'adresse 768, etc. Une ligne de cache de 256 octets contiendra une donnée provenant d'un bloc de RAM de 256 octets, dont l'adresse est systématiquement un multiple de 256. Il n'est pas possible qu'une ligne de cache contienne un bloc de 256 octets dont l'adresse du premier octet serait l'adresse 64, ou l'adresse 32, par exemple. En clair, les adresses de ces blocs sont des multiples de la taille de la ligne de cache, de la taille des blocs. Cela rappelle les contraintes d'alignement vues dans le chapitre "Le modèle mémoire : alignement et boutisme", mais appliquées aux lignes de cache. L'alignement des lignes de cache a des conséquences pratiques pour la conception des caches. Notons qu'il est en théorie possible d'avoir des caches dont les lignes de cache ne sont pas alignées, mais cela poserait des problèmes majeurs. Il serait en effet possible qu'une donnée soit présente dans deux lignes de cache à la fois. Par exemple, prenons le cas où une ligne de cache de 256 commence à l'adresse 64 et une autre ligne de cache commence à l'adresse 0. L'adresse 128 serait dans les deux lignes de cache ! Et cela poserait des problèmes lors des lectures, mais encore plus lors des écritures. C'est pour éviter ce genre de problèmes que les lignes de cache sont alignées avec la mémoire RAM dans tous les caches existants. L'alignement des lignes de cache est une chose que les programmeurs doivent parfois prendre en compte quand ils écrivent du code ultra-optimisé, destiné à des programmes demandant des performances extrêmes. Il arrive que les contraintes d'alignement posent des problèmes. Nous avions vu dans le chapitre sur le boutisme et l'alignement qu'il valait mieux gérer l'alignement des variables des structures de données, pour éviter les accès non-alignés avec le bus mémoire. La même chose est possible, mais pour l'alignement avec des lignes de cache. Typiquement, l'idéal est que, pour une structure de donnée, on puisse en mettre un nombre entier dans une ligne de cache. Ou alors, si la structure est vraiment grande, que celle-ci occupe un nombre entier de lignes de cache. Si ce n'est pas le cas, il y a un risque d'accès non-alignés, c'est à dire qu'une structure se retrouve à cheval sur deux lignes de cache, avec les défauts que cela implique. ===Le tag d'une ligne de cache=== Plus haut, nous avions dit que le cache mémorise, pour chaque ligne de cache, l'adresse RAM associée. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Mais du fait de l'organisation du cache en lignes de cache de grande taille, qui sont de plus alignées en mémoire, il faut nuancer cette affirmation. Le cache ne mémorise pas la totalité de l'adresse, ce qui serait inutile. L'alignement des lignes de cache en RAM fait que les bits de poids faible de l'adresse ne sont pas à prendre en compte pour l'association adresse-ligne de cache. Dans ces conditions, on mémorise seulement la partie utile de l'adresse mémoire correspondante, qui forme ce qu'on appelle le '''tag'''. Le reste de l'adresse indique quelle est la position de la donnée dans la ligne de cache. Par exemple, prenons le cas où le processeur gère des nombres entiers de 64 bits (8 octets) et des lignes de cache de 128 octets : chaque ligne de cache contient donc 16 entiers. Si le processeur veut lire ou écrire un entier bien précis, il doit préciser sa place dans la ligne de cache. Et ce sont les bits de l'adresse mémoire non-inclus dans le cache qui permettent de faire ça. En clair, une adresse mémoire à lire/écrire est interprété par le cache comme la concaténation d'un tag et de la position de la donnée dans la ligne de cache correspondante. [[File:Adressage d'un cache totalement associatif.png|centre|vignette|upright=2|Adressage d'un cache totalement associatif]] Le cache est donc une grande table de correspondance entre tags et lignes de cache. Lors d'un accès mémoire, le cache extrait le tag de l'adresse à lire ou écrire, et le compare avec les tags de chaque ligne de cache. Si une ligne contient ce tag, alors c'est que cette ligne correspond à l'adresse, et c'est un défaut de cache sinon. Lors d'un succès de cache, la ligne de cache est lue depuis le cache et envoyée à un multiplexeur qui sélectionne la donnée à lire dans la ligne de cache. Le fonctionnement est similaire pour une écriture : la donnée à écrire passe dans un démultiplexeur, qui envoie la donnée au bon endroit dans la ligne de cache sélectionnée. [[File:Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.png|centre|vignette|upright=2|Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.]] ===Le contenu d'une ligne de cache=== Dans ce qui va suivre, nous allons considérer que chaque ligne de cache mémorise son tag, les données de la ligne de cache proprement dit, et quelques bits de contrôle annexes qui varient suivant le cache considéré. [[File:Tag d'une ligne de cache.png|centre|vignette|upright=2|Tag d'une ligne de cache.]] Les caches modernes incluent de nombreux bits de contrôle, mais deux d'entre eux sont communs à presque tous les caches modernes : le bit ''Dirty'' et le bit ''Valid''. Le '''bit ''Valid''''' indique si la ligne de cache contient des données valides ou non. Si le bit ''Valid'' est à 0, la ligne de cache est en état valide, à savoir qu'elle contient des données et n'est pas vide. Par contre, si ce bit est à 1, la ligne de cache est invalide et son contenu ne peut pas être lu ou écrit. L'utilité de ce bit est qu'il permet d'effacer une ligne de cache très rapidement : il suffit de mettre ce bit à 0. Il existe des situations où le cache doit être effacé, on dit alors qu'il est invalidé. Une section de ce chapitre sera dédié à l'invalidation du cache. Le '''bit ''Dirty''''' indique qu'une ligne de cache a été modifiée. Par modifiée, on veut dire que le processeur a écrit dedans, qu'il a modifié la ligne de cache. Mais attention : si la donnée a été modifiée dans le cache, la modification n'est pas forcément propagée en mémoire RAM. Le bit ''dirty'' indique si c'est le cas, si l'écriture a été propagée en mémoire RAM. Il précise que la ligne de cache contient des données modifiées, alors que la RAM a des données initiales non-modifiées. Une ligne de cache avec un bit ''dirty'' à 1 est dite ''dirty'', par métonymie. Nous verrons cela en détail dans la section sur les caches ''write-back'' et ''write-through''. Les caches modernes ajoutent des '''bits de détection/correction d'erreur''' dans les bits de contrôle. Pour rappel, les codes de détection/correction d'erreur permettent de se prémunir contre des erreurs matérielles, qui corrompent les données stockées dans une mémoire, ici une mémoire cache. Ils ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Nous reviendrons dessus dans une section ultérieur de ce chapitre. Sur certains caches assez anciens, on pouvait transférer les lignes de caches morceaux par morceaux. Ces caches avaient des lignes de cache divisées en sous-secteurs, ces sous-secteurs étant des morceaux de ligne de cache qu'on pouvait charger indépendamment les uns des autres (mais qui sont consécutifs en RAM). Chaque secteur avait ses propres bits de contrôle, mais le tag était commun à tous les secteurs. [[File:Cache à secteurs.png|centre|vignette|upright=2.5|Cache à secteurs.]] : Dans ce qui va suivre, le terme "ligne de cache" désignera soit un bloc de données copiées depuis la RAM d'une taille de 64/128/256/... octets, soit la concaténation de ces données avec le tag et des bits de contrôle. Les deux définitions ne sont pas équivalentes, mais l'usage a entériné cet abus de langage. Et il faut avouer que cela rend les explications du chapitre plus simples. ==Les instructions de contrôle du cache== Plus haut, nous avions dit que le cache est totalement transparent du point de vue du programmeur. Le cache contient des copies de données en RAM, le programmeur n'a rien à faire pour utiliser le cache correctement. Mais la réalité est que pour des raisons diverses, des processeurs incorporent des '''instructions de contrôle du cache'''. Il s'agit d’instructions qui agissent sur le contenu du cache. Elles existent pour des raisons diverses qu'on détaillera plus bas, mais il s'agit globalement d'une question de performances ou de nécessité pour le système d'exploitation. ===Les instructions de préchargement=== La première instruction de contrôle du cache est une '''instruction de préchargement''', qui demande à charger un bloc de données dans le cache. Elle prend en opérande une adresse mémoire, et le contenu de cette adresse est chargé dans une ligne de cache. Bien sûr, des contraintes d'alignement sont à prendre en compte : on charge un bloc de la même taille qu'une ligne de cache, aligné en mémoire sur la taille du bloc, qui contient l'adresse. L'instruction de préchargement n'est utile que si l'instruction est exécutée bien avant que la donnée ne soit utilisée/lue/écrite. Cela permet de charger une donnée dans le cache à l'avance, d'où le nom de préchargement donné à cette technique. Mais les processeurs modernes gérent des techniques de préchargement automatique, qui ne requièrent pas d'instructions de préchargement. Le préchargement automatique et les instructions de préchargement sont deux solutions complémentaires, mais qui peuvent se marcher sur les pieds. Nous en reparlerons dans le prochain chapitre, qui sera dédié au préchargement automatique. Il faut noter que les instructions de préchargement peuvent être ignorées par le processeur. Sous certaines conditions, le processeur peut décider que l'instruction de préchargement ne sera pas exécutée. Par exemple, il ne va pas précharger une donnée déjà présente dans le cache. Ou encore, si le bus mémoire est occupé, il ne va pas exécuter le préchargement, par manque de ressources matérielles. ===Les instructions d'invalidation et de ''flush''=== Les instructions ''flush'' regroupent deux types d'instructions qui sont souvent utilisées en même temps. Il s'agit des instructions d'invalidation et de nettoyage (''clean''). Les deux termes proviennent de la terminologie ARM, il n'y a pas de terminologie standardisé pour les noms de ces instructions. Dans les grandes lignes, elles permettent de vider le cache, à savoir de rapatrier son contenu en RAM et de réinitialiser le cache à zéro. Elles sont utilisées par le système d'exploitation lors des commutations de contexte, à savoir quand on passe d'un programme à un autre. Elles sont aussi utilisées lors des appels systèmes et routines d'interruption/exception. L'idée est de vider le cache avant d'exécuter un nouveau programme ou une nouvelle routine. Le nouveau programme aura accès à un cache tout propre, les données de l'ancien programme auront été retirée du cache. Les '''instructions ''clean''''' recopient le contenu de la ligne de cache en RAM. Elles forcent la recopie immédiatement de la ligne de cache en mémoire RAM. Pour faire leur travail, elle vérifient si la ligne de cache a été modifiée, avant de la recopier en RAM. Et pour cela, ils vérifient le bit de contrôle ''dirty'', qui est mis à 1 après une première écriture. Si ce bit est à 0, alors pas besoin de recopier la ligne de cache : elle n'a pas été modifiée, la RAM a déjà la bonne copie. Mais s'il est à 1, le cache et la RAM n'ont pas le même contenu, la recopie s'exécute. Les '''instructions d'invalidation''' permettent d'invalider une ligne de cache, à savoir d'effacer son contenu. Nous verrons à quoi servent ces instructions dans la section sur les changement de processus. Invalider une ligne de cache est une opération optimisée : le cache n'est en réalité pas réellement effacé. À la place, le bit ''Valid'' de chaque ligne de cache est juste mis à 0. Il faut noter que l'invalidation efface les lignes de cache sans se préoccuper de leur contenu. Elle se moque qu'une ligne de cache contienne une donnée modifiée, ''dirty'' ou quoique ce soit : la ligne de cache est effacée, point. Il est possible d'invalider une ligne de cache en fournissant une adresse mémoire, mais il est aussi possible d'invalider le cache tout entier. Le choix entre les deux dépend du mode d'adressage de l'instruction d'invalidation. Parfois, il existe une instruction séparée pour invalider tout le cache, et une autre pour invalider une ligne de cache bien précise. Des instructions séparées sont parfois disponibles pour invalider les caches de données et d'instructions, parfois aussi la TLB (un cache qu'on verra dans quelques chapitres). Il est possible de n'invalider que le cache L1, voire le cache L2. Il faut noter que l'invalidation efface tout le cache, mais ne se préoccupe pas de vérifier si les données ont été modifiées dans le cache. Pour certains caches, comme le cache d'instruction, ce n'est pas un problème, vu qu'il est en "lecture seule". Mais pour les caches de données, les données modifiées sont perdues en cas d'invalidation. Heureusement, il existe des instructions d'invalidation qui fusionnent une instruction ''clean'' et une instruction d'invalidation. Il s'agit d''''instructions d'invalidation spéciales'''. ===Les instructions d'optimisation : instructions non-temporelles et écritures optimisées=== Les '''instructions mémoire non-temporelles''' contournent complètement le cache. Par exemple, une lecture peut lire une donnée, mais celle-ci ne sera pas chargée dans le cache, elle passe directement de la RAM vers les registres. Une section entière de ce chapitre sera dédiée au contournement du cache, à savoir aux situations où les accès mémoire doivent passer directement du processeur à la RAM sans passer par le cache. D'autres instructions assez rares incorporent des indications pour le cache. Par exemple, l'instruction ''load last'' des processeurs POWER PC implique que la donnée ne sera utilisée qu'une seule fois. Elle est donc chargée dans le cache, mais la ligne de cache est configurée de manière à être remplacée très rapidement, typiquement avec une valeur de LRU/LFU adéquate. La donnée est bien chargée dans le cache, au cas où elle doive être relue suite à une mauvaise prédiction de branchement ou autre, chose qu'une lecture non-temporelle (qui contourne le cache) ne fait pas. Des indications de ce type sont appelées des '''''cache hint'''''. L''''instruction ''flush''''' permet de préciser qu'une ligne de cache contient une donnée inutile, qui ne sera pas réutilisée par le programme. Pas besoin de la conserver dans le cache, elle peut laisser sa place à des données plus utiles. Or, sans indication, les algorithmes de remplacement d'une ligne de cache risquent de conserver cette donnée trop longtemps, ce qui entraine une certaine pollution du cache par des données inutiles. Une autre instruction est elle beaucoup plus importante : celle de '''pré-allocation sur écriture'''. Elle sert dans le cas où une ligne de cache est complétement écrite. Par exemple, imaginons qu'on veuille écrire dans une portion de mémoire. Si celle-ci n'est pas dans le cache, le processeur va charger une ligne de cache complète depuis la RAM, écrire dans la ligne de cache, puis recopier la ligne de cache modifiée en mémoire RAM. Une écriture en RAM demande donc de faire une lecture et une écriture. Mais les instructions de pré-allocation sur écriture permettent de prévenir qu'une ligne de cache sera intégralement écrite, et qu'il n'y a donc pas besoin de lire celle-ci depuis la RAM. Notons que l'instruction d'écriture qui suit n'est pas une écriture non-temporelle, vu que les données sont écrites dans la ligne de cache, qui est ensuite envoyée en mémoire RAM dès que nécessaire. De plus, les données écrites peuvent ensuite être relue depuis le cache si nécessaire. Enfin, certains processeurs MIPS incorporent une instruction pour modifier le tag d'une ligne de cache. Elles servent à optimiser les copies mémoire, à savoir quand on copie un bloc de données d'un endroit à un autre. L'idée est de charger le bloc de données dans le cache avec une instruction LOAD/PREFETCH, de modifier le tag pour qu'il pointe vers l'adresse à écrire, et de laisser faire le cache pour que l'écriture se fasse en RAM. Mais les contraintes pour utiliser cette instruction sont assez drastiques : les données doivent être alignées sur la taille d'une ligne de cache, le bloc de départ et d'arrivée (l'original versus la copie) ne doivent pas se recouvrir, etc. ==L'associativité des caches et leur adressage implicite== Lorsqu'on souhaite accéder au cache, il faut trouver quelle est la ligne de cache dont le tag correspond à l'adresse demandée. On peut classifier les caches selon leur stratégie de recherche de la ligne correspondante en trois types de caches : totalement associatifs, directement adressés (''direct mapped'') et associatifs par voie. ===Les caches totalement associatifs=== Avec les caches totalement associatifs, toute donnée chargée depuis la mémoire peut être placée dans n'importe quelle ligne de cache, sans aucune restriction. Ces caches ont un taux de succès très élevé, quand on les compare aux autres caches. [[File:Cache totalement associatif.png|centre|vignette|upright=2|Cache totalement associatif.]] Concevoir un cache totalement associatif peut se faire de deux grandes manières différentes. La première consiste tout simplement à combiner une mémoire associative avec une mémoire RAM, en ajoutant éventuellement quelques circuits annexes. La mémoire associative mémorise les tags, alors que la mémoire RAM mémorise les données de la ligne de cache, éventuellement avec quelques bits de contrôle. La ligne de cache est stockée à une adresse A dans la mémoire RAM et son tag est stocké à la même adresse, mais dans la mémoire CAM. Ce faisant, quand on envoie le tag à la mémoire CAM, elle renvoie l'adresse de la ligne de cache dans la mémoire RAM. Cette adresse est alors envoyée directement sur le bus d'adresse de la RAM, et la lecture est effectuée automatiquement. Il faut ajouter quelques circuits annexes pour garantir que les écritures se passent correctement dans les deux mémoires, mais rien de bien terrible. [[File:Cache fabriqué avec une mémoire associative et une RAM.png|centre|vignette|upright=3|Cache fabriqué avec une mémoire associative et une RAM]] Il est cependant possible d'optimiser un tel cache, en fusionnant la mémoire CAM et la mémoire RAM, afin d'éliminer des circuits redondants. Pour comprendre pourquoi, rappelons que les mémoires CAM sont composées d'un plan mémoire, d'un paquet de comparateurs et d'un encodeur. Quant à la mémoire RAM, elle est composée d'un décodeur connecté au plan mémoire. En mettant une CAM suivie d'une RAM, on a un encodeur dont l'entrée est envoyée à un décodeur. [[File:Cache totalement associatif naif.png|centre|vignette|upright=3|Cache totalement associatif naif]] Or, le décodeur réalise l'opération inverse de l'encodeur, ce qui fait que mettre les deux composants à la suite ne sert à rien. On peut donc retirer l'encodeur et le décodeur, et envoyer directement les résultats des comparateurs sur les entrées de commande du plan mémoire de la RAM. [[File:Cache totalement associatif optimisé.png|centre|vignette|upright=2|Cache totalement associatif optimisé]] Avec cette méthode, les circuits du cache ressemblent à ce qui illustré ci-dessous. Le tag est envoyé à chaque ligne de cache. Le tag envoyé est alors comparé avec le Tag contenu dans chaque ligne de cache, comme c'est le cas sur les mémoires associatives. Si une ligne de cache matche avec le tag envoyé en entrée, la ligne pour laquelle il y a eu une égalité est alors connectée sur les lignes de bit (''bitlines''). Cela est réalisé par un circuit commandé par le comparateur de la ligne de cache. Il ne reste plus qu'à sélectionner la portion de la ligne de cache qui nous intéresse, grâce à un paquet de multiplexeurs. Cela permet d'effectuer une lecture ou écriture, mais il faut aussi préciser si il y a eu un défaut de cache ou un succès. Un succès de cache a lieu quand au moins des comparaisons est positive, alors que c'est un défaut de cache sinon. En clair, détecter un succès de cache demande juste de connecter une porte OU à plusieurs entrées à tous les comparateurs. [[File:Organisation générale d'un cache totalement associatif.png|centre|vignette|upright=2|Organisation générale d'un cache totalement associatif.]] ===Les caches directement adressés=== Les caches directement adressés peuvent être vus comme un cache totalement associatif auquel on aurait ajouté des restrictions assez drastiques. Plus haut, on a vu qu'un cache totalement adressé est équivalent à la combinaison d'une CAM avec une RAM. La mémoire CAM prend en entrée un Tag et traduit celui-ci en une adresse qui commande la mémoire RAM interne au cache. Dans ce qui suit, l'adresse interne au cache sera appelé l''''indice''' pour éviter toute confusion. [[File:Cache hash table - 2.png|centre|vignette|upright=2|Fonctionnement interne du cache, expliquée sous forme abstraite, en utilisant la notion d'indice interne au cache.]] Les caches directement adressés cherchent à remplacer la mémoire CAM par un circuit combinatoire. Ce circuit traduit le Tag en indice, mais est beaucoup plus simple qu'une mémoire CAM. Mais qui dit circuit plus simple dit circuit plus limité. Un circuit combinatoire n'est pas aussi versatile que ce qui est permis avec une mémoire CAM. En conséquence, une restriction majeure apparait : toute adresse mémoire est associée dans une ligne de cache prédéfinie, toujours la même. L'association entre ligne de cache et adresse mémoire est faite par le circuit combinatoire, et ne peut pas changer. Les concepteurs de caches s'arrangent pour que des adresses consécutives en mémoire RAM occupent des lignes de cache consécutives, par souci de simplicité. Tout se passe comme suit la mémoire RAM était découpés en blocs de la même taille que le cache. La première adresse du bloc est associée à la première ligne de cache (celle d'indice 0), la seconde adresse est associée à la seconde adresse du_ bloc, et ainsi de suite. Le tout est illustré ci-dessous. [[File:Cache adressé directement.png|centre|vignette|upright=2|Cache adressé directement.]] Avec cette contrainte, le circuit de traduction de l'adresse en adresse mémoire pour la RAM interne au cache est drastiquement simplifié, et disparait même. Une partie de l'adresse mémoire sert à indiquer la position de la donnée dans le cache, le reste de l'adresse sert encode le tag et la position de la donnée dans le ligne de cache. [[File:Cache line.png|centre|vignette|upright=2|Adresse d'une ligne de cache sur un cache adressé directement.]] Un cache directement adressé est conçu avec une RAM, un comparateur, et un paquet de multiplexeurs. En général, la mémoire RAM stocke les lignes de caches complète. Il arrive que l'on utilise deux mémoires RAM : une pour les tags et une pour les données, mais cette technique augmente le nombre de circuits et de portes logiques nécessaires, ce qui réduit la capacité du cache. L'index à lire/écrire est envoyé sur l'entrée d'adresse de la RAM, la RAM réagit en mettant la ligne de cache sur sa sortie de donnée. Sur cette sortie, un comparateur compare le tag de la ligne de cache lue avec le tag de l'adresse à lire ou écrire. On saura alors si on doit faire face à un défaut de cache. Ensuite, un multiplexeur récupère la donnée à lire/écrire. [[File:Direct mapped cache - french.png|centre|vignette|upright=2|Cache directement adressé.]] L'accès à un cache directement adressé a l'avantage d'être très rapide vu qu'il suffit de vérifier une seule ligne de cache : celle prédéfinie. Mais ces caches ne sont cependant pas sans défauts. Vu que le cache est plus petit que la mémoire, certaines adresses mémoires se partagent la même ligne de cache. Si le processeur a besoin d’accéder fréquemment à ces adresses, chaque accès à une adresse supprimera l'autre du cache : tout accès à l'ancienne adresse se soldera par un défaut de cache. Ce genre de défauts de cache causés par le fait que deux adresses mémoires ne peuvent utiliser la même ligne de cache s'appelle un '''défaut par conflit''' (''conflict miss''). Les défauts par conflit n'existent pas sur les caches totalement associatifs. En conséquence, le taux de succès des caches directement adressés est assez faible comparé aux autres caches. [[File:Cache Block Basic Conflict.svg|centre|vignette|upright=1.5|Exemple de ''Conflict Miss''.]] ===Les caches associatifs par voie=== Les caches associatifs par voie sont un compromis entre les caches directement adressés et les caches totalement associatifs. Pour simplifier, ces caches sont composés de plusieurs caches directement adressés accessibles en parallèle, chaque cache/RAM étant appelé une '''voie'''. Avec ces caches, toute adresse mémoire en RAM est associée à une ligne de cache dans chaque voie. [[File:Cache associatif par voie.png|centre|vignette|upright=2|Cache associatif par voie.]] Le schéma ci-dessous compare un cache directement adressé et un cache associatif à deux voies. On voit que chaque adresse est associée à une ligne de cache bien précise avec un cache directement dressé, et à deux lignes de cache avec un cache associatif à deux voies. L'adresse sera associée à 4 lignes de cache sur un cache associatif à 4 voies, à 8 lignes pour un cache à 8 voies, etc. L'ensemble des lignes de cache associées à une adresse est appelé un '''ensemble'''. [[File:Cache Fill.svg|centre|vignette|upright=2|Comparaison entre un cache directement adressé et un cache associatif à deux voies.]] Sur ces caches, toute adresse est découpée en trois parties : un tag, un index, et un décalage, comme sur les caches directement adressés. Comme vous pouvez le voir, l'organisation est identique à celle d'un cache totalement associatif, à part que chaque ensemble tag-ligne de cache est remplacé par une mémoire RAM qui en contient plusieurs. [[File:Implémentation d'un cache associatif par voie.png|centre|vignette|upright=2|Implémentation d'un cache associatif par voie.]] Le risque de conflits d'accès au cache est donc réduit sur un cache associatif à plusieurs voies, et il est d'autant plus réduit que le cache a de voies. Par contre, leur conception interne fait qu'ils ont un temps d'accès légèrement élevé que les caches directement adressés. Les caches associatifs par voie ont donc un taux de succès et un temps d'accès intermédiaire, situé entre les caches directement adressés et totalement associatifs. Ils sont une sorte de compromis entre réduction des défaut par conflits d'accès au cache et temps d'accès, et complexité des circuits. ==Les optimisations des caches associatifs par voie== Les caches partiellement associatifs regroupent les caches associatifs par voie et directement adressés, ainsi que leurs variantes. En clair : tous les caches qui ne sont pas totalement associatifs. Ils peuvent être optimisés de nombreuses manières, que ce soit pour gagner en performance ou pour économiser de l’énergie. Dans cette section, nous allons voir quelles sont ces optimisations. ===Les caches pseudo-associatifs=== Les caches adressés par voie contiennent une mémoire SRAM par voie. En théorie, les voies sont accédées en parallèles, en même temps, afin de voir si l'on a un succès de cache ou un défaut. Les '''caches pseudo-associatifs''' sont identiques aux caches associatifs par voie, si ce n'est qu'ils vérifient chaque voie une par une. Ils ont été utilisés sur des processeurs commerciaux, un exemple étant l'IBM 370. Là encore, on perd en performance pour gagner en consommation d'énergie. Le temps d'accès dans le meilleur des cas est plus faible pour les caches pseudo-associatifs, mais le pire des cas teste tous les caches avant de tomber sur le bon. Les performances sont donc réduites. Mais la consommation énergétique est meilleure, vu qu'on ne vérifie pas forcément toutes les voies en parallèle. On teste la première voie, éventuellement la seconde, peut-être la troisième, etc. Mais dans le cas général, on ne teste qu'une partie des voies, pas toutes, ce qui donne un gain en termes d'énergie. L'implémentation de caches de ce genre demande que l'on parcoure les voies une par une, en commençant de la première jusqu'à la dernière. Pour cela, un simple compteur suffit. Suivant la valeur du compteur, la voie associée est activée puis accédée. Toute la complexité revient à ajouter un circuit qui prend la valeur du compteur, et active la voie associée, lance un accès mémoire dessus. Vu que les voies sont chacune des caches ''direct mapped'', il suffit pour cela de geler les entrées d'adresse, soit en les déconnectant, soit en utilisant du ''clock gating'' ou de l'évaluation gardée. Les détails d'implémentation, non-cités ici, varient selon le cache. ===La prédiction de voie=== Pour réduire le temps d'accès des caches pseudo-associatifs, certains chercheurs ont inventé la '''prédiction de voie''', qui consiste à faire des paris sur la prochaine voie accédée. L'idée est d'accéder à la voie qui contient la donnée voulue du premier coup, en lisant celle-ci en priorité. Dans son implémentation la plus simple, le cache reste un cache pseudo-associatif. Lors d'un accès au cache, les voies sont toutes parcoures une par une. Par contre, les voies ne sont donc pas parcourues de la première vers la dernière, mais dans un ordre différent. Cette technique permet de mettre en veille les voies sur lesquels le processeur n'a pas parié, ce qui permet de diminuer la consommation énergétique du processeur. C'est plus efficace que d'aller lire plusieurs données dans des voies différentes et de n'en garder qu'une. L'implémentation est assez simple : il suffit d'ajouter un circuit de prédiction de voie,relié au compteur de voie. Une amélioration de la technique fait fonctionner le cache comme un intermédiaire entre cache pseudo-associatif et associatif par voies. L'idée est de chercher la voie prédite en premier, puis de chercher dans toutes les voies en parallèle en cas de défaut de cache. Au lieu d'attendre que les comparaisons de tags donnent leur résultat, le processeur sélectionne automatiquement une voie et configure les multiplexeurs à l'avance. Si le processeur ne se trompe pas, le processeur accède à la donnée plus tôt que prévu. S'il se trompe, le processeur annule la lecture effectuée en avance et recommence en faisant un accès en parallèle aux autres voies. Le compromis entre performance et consommation d'énergie est alors différent. On économise de l'énergie par rapport à un cache associatif par voie, au prix d'une petite perte de performance (doublement des temps d'accès). Mais par rapport à un cache pseudo-associatif, l'économie d'énergie est bien moindre, au prix d'un gain en performance assez manifeste. Prédire quelle voie sera la bonne est assez simple. En vertu du principe de localité, les accès futurs ont des chances de tomber dans les voies les plus fréquemment utilisées ou dans celle plus récemment utilisée. Il suffit de retenir la voie la plus récemment accédée dans un registre, qui sera utilisée comme prédiction. Pour vérifier que la prédiction est correcte, il suffit de comparer le registre et le résultat obtenu après vérification des tags. Cependant, on peut complexifier l'implémentation pour prendre en compte l'adresse à lire/écrire, l'instruction à l'origine de l'accès mémoire ou tout autre paramètre utile. Par exemple, des instructions différentes ont tendance à aller chercher leurs données dans des ensembles différents et la voie à choisir n'est pas la même. Pour cela, il suffit d'utiliser un cache pour stocker la correspondance instruction - voie. Pour plus de simplicité, la mémoire cache des prédictions est parfois remplacée par une RAM, qui est adressée : * soit par le program counter de l'instruction à l'origine de l'accès (en réalité, seulement quelques bits de poids faible de l'adresse) ; * soit par l'adresse à accéder (là encore, quelques bits de poids faible) ; * soit (pour les modes d'adressage qui utilisent un registre de base et un décalage) par un XOR entre les bits de poids faible de l'adresse de base et le décalage ; * soit par autre chose. ===La mise en veille sélective des voies=== Les caches associatifs ont tendance à utiliser beaucoup d'énergie, même quand on n'y accède pas. Aussi, certains processeurs détectent quand le cache est peu utilisé et en profitent pour mettre en veille les voies inutilisées. Vous vous demandez certainement ce qui se passe quand une donnée à lire/écrire est dans une voie désactivée. La réponse est que le cache détecte cette situation, car elle déclenche un succès de cache. Les ''tags'' ne sont en effet pas désactivés, seules les données sont mises en veille. L'implémentation est plus simple sur les caches qui séparent les tags et les données dans deux RAM différentes. Cette optimisation marche surtout sur les gros caches, qui ont des chances d'avoir une portion significative d’inutilisée (pas assez de données pour les remplir), donc généralement les caches L3/L4. Par exemple, les processeurs d'Intel de microarchitecture Ivy Bridge disposent d'un cache de 8 mébioctets à 16 voies, qu'ils peuvent faire passer à 512 kibioctets si le besoin s'en fait sentir. Quand ces processeurs détectent une faible activité, ils mettent en veille 14 voies et n'en gardent que 2 d'actives. Évidemment, les 14 voies sont vidées avant d'être mises en veille, afin qu'une aucune donnée ne soit perdue. ===Les caches ''skew-associative''=== Vous aurez remarqué que dans une voie, les lignes sont accédées en adressage direct : les défauts par conflit sont possibles sur un cache associatif par voie. Pour éviter cela, certains chercheurs ont créé des '''caches ''skew associative''''' (ou associatifs à biais). Pour faire simple, les index des lignes de cache subissent un petit traitement avant d'être utilisés. Le traitement en question est différent suivant la voie de destination, histoire que deux adresses mémoires avec des index identiques donnent des index différents après traitement. Le traitement en question est souvent une permutation des bits de l'index, qui est différente suivant la voie prise, ou un simple XOR avec un nombre qui dépend de la voie. [[File:Implémentation d'un cache skew associative.jpg|centre|vignette|upright=2|Implémentation d'un cache skew associative.]] ==Les caches splittés (''phased caches'')== Dans cette section, nous allons voir les '''caches splittés''' (''phased caches''), qui sont une variante des caches ''direct-mapped'', dans lequel le cache est accédé en deux étapes consécutives. Il ne s'agit pas des caches pipelinés, que nous verrons dans le chapitre sur les processeurs pipélinés, mais laissons cela à plus tard. Il est possible d'appliquer la même méthode sur un cache associatif par voie, mais il y a des méthodes plus simples, qui permettent là aussi d’accéder au cache en plusieurs étapes consécutives. L'idée est de scinder le cache en deux : une mémoire pour les tags, une autre pour les données de la ligne de cache. Les bits de contrôle peuvent être mis dans l'une ou l'autre SRAM, mais ils sont souvent mis dans la RAM pour les tags. En faisant cela, quelques optimisations deviennent possibles, afin de réduire la consommation énergétique en contrepartie d'une perte de performance. La technique s'implémente différemment pour les caches totalement associatifs et partiellement associatifs. Les caches totalement associatifs splittés sont ceux formés en combinant un cache associatif avec une CAM et une RAM combinée. On envoie l'adresse à lire/écrire à la mémoire associative, elle répond en envoyant une adresse à la mémoire RAM. L'accès se fait donc en deux temps, avec l'adresse dans la RAM comme intermédiaire. Il est possible de séparer physiquement les deux étapes en insérant un registre entre la CAM et la RAM, ce qui permet aussi de pipeliner l'accès. Mais c'est rarement fait en pratique, car le cout en circuit d'une mémoire CAM est trop important. L'équivalent pour un cache totalement associatif optimisé, sans CAM et RAM séparée, est trop gourmande en interconnexions pour être implémentée. Les caches totalement associatifs splittés sont donc très rares, l'auteur ne connait aucun exemple de processeur avec un tel cache. Il existe une technique équivalente pour les caches ''direct-mapped'', mais elle demande une certaine modification du cache. Dans les caches ''direct-mapped'' non-splittés, on trouve une mémoire SRAM dont chaque mot mémoire contient une ligne de cache entière, tag inclus. Dans leurs versions splittés, la SRAM est séparée en deux : une pour les tags, une autre pour les données. Précisons qu'il s'agit bien de deux mémoires SRAM adressables. L'adresse à laquelle accéder est envoyée à la SRAM des tags, puis ensuite à la SRAM des données si besoin. L'idée est d’accéder aux tags pour déterminer s'il y a un succès de cache ou un défaut, et ensuite d'accéder aux données. On n’accède pas aux données en parallèle des tags. Faire cela est évidemment plus lent. En cas de défaut de cache, le temps d'accès est similaire : le tag ne correspond pas, on n'accède pas à la SRAM pour les données. Par contre, vu qu'on n'a pas activé la SRAM pour les données, on économise un peu d'énergie, ce qui réduit la consommation d'énergie. En cas de succès de cache, on accède à la SRAM pour les tags, puis à celle pour les données. Pas d'économie d'énergie à l'horizon, sans compter que le temps d'accès augmente : on accède au cache en deux étapes au lieu de faire les deux accès en parallèle. [[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]] Précisons cependant que ce design peut avoir deux avantages en termes de performance. Premièrement, le temps d'accès au cache est légèrement amélioré en cas de défaut de cache. En effet, la SRAM des tags est assez petite, idem pour celle des données. Leur temps d'accès est donc plus faible que pour une grosse SRAM contenant données et tags. Le gain en temps d'accès est donc un avantage, qui ne se manifeste surtout en cas de défaut de cache. Un autre avantage est que l'accès au cache se pipeline plus facilement, ce qui fait qu'on peut effectuer plusieurs accès simultanés au cache. Mais nous verrons cela dans quelques chapitres. ===L'exemple des processeurs 386 et du contrôleur de cache 82385=== Il est important de noter que la séparation entre tags et RAM peut être telle que les deux ne sont pas sur la même puce de silicium ! Voire que les deux sont séparés du processeur ! C'était le cas quand les mémoires caches ont été introduites sur les processeurs grand public, notamment sur les premiers processeurs Intel. La miniaturisation n'avait pas avancé au point où placer un cache dans le processeur était possible. Sur le processeur 386 d'Intel, le cache était un cache splitté, séparé du processeur. Concrètement, le processeur i386 était couplé à un contrôleur de cache Intel 82385 et une mémoire SRAM. Le 82385 contenait les ''tags'' et les bits de contrôle, la SRAM contenait les données, les lignes de cache. Un point important est que les lignes de cache faisaient seulement 32 bits/4 octets, pas plus ! On était loin des lignes de cache actuelles, faisant 64 octets/512 bits. Mais c'était beaucoup plus pratique, vu que le bus système faisait 32 bits de large, idem pour l'interface avec le processeur. Pour intégrer un cache facultatifs (certains ordinateurs 'en passaient). Le schéma ci-dessous montre comment le cache s'intégrait avec le bus système. Pour le bus de commande, le cache servait d'intermédiaire : il recevait les commandes et et les filtrait suivant les succès/défauts de cache. En cas de succès de cache, les commandes de lecture n'étaient pas envoyées à la mémoire RAM. Les adresses étaient transmises à la fois au cache et au bus système (avec un registre entre le bus système et le processeur). Le bus de donnée était lui connecté à la mémoire SRAM et au processeur, avec des MUX/DEMUX pour faire le choix de la source des lectures. [[File:Controleur de cache 82385 pour l'Intel 386.png|centre|vignette|upright=2.5|Contrôleur de cache 82385 pour l'Intel 386]] Le 82385 surveillait ce qui se passait sur le bus et répondait à la place de la RAM pour certaines lectures. C'était un intermédiaire assez passif, qui se contenait de répondre aux succès et défauts en lecture. Le cache était un cache ''write through'' un peu particulier. En cas de succès de cache pour une écriture, le cache met à jour sa ligne de cache et propage l'écriture en mémoire RAM. Par contre, si une écriture fait un défaut de cache, la donnée n'est pas écrite dans le cache. Le seul moyen pour copier une donnée dans le cache était un défaut pour une lecture. Le 82385 pouvait commander soit un cache ''direct mapped'', soit associatif à deux voies. La différence entre les deux est que le 82385 était relié à une mémoire SRAM avec un cache ''direct mapped'', deux SRAM pour deux voies. Pour avoir un cache associatif à deux voies, le 82385 devrait gérer deux signaux ''chip select'' pour activer chaque SRAM/voie suivant les besoins. Il avait précisément quatre signaux CS : deux par SRAM, un pour les lectures, un pour les écritures. Notons que les SRAM utilisées devaient être des SRAM avec un bus de données de 32 bits, des SRAM 16 ou 8 bits ne fonctionnaient pas. Le 82385 rajoutait 4 sorties, pour masquer chaque octet dans ces 32 bits. Le choix entre les deux était le fait d'une entrée : la mettre à 0 indiquait un cache ''direct mapped'', la mettre à 1 forçait un cache à deux voies. Il gérait aussi les accès mémoire non-cacheable, à savoir des accès mémoire qui ne doivent pas être pris en compte par le cache. Il considérait certains accès mémoire comme "à ne pas cacher". Notamment, les accès mémoire à une entré-esortie ne sont pas cachés. Pour rappel, le processeur utilisait un espace d'adressage séparé pour les entrées-sorties, et utilisait donc un bit IO, qui était utilisé par le 82385 pour savoir si l'accès mémoire doit être caché ou non. Il en est de même pour les accès ayant lieu lors d'une interruption, qui ne passent pas par le cache. Mais au-delà de cette inhibition automatique du cache, le 82385 avait une entrée NCA (''Non Cacheable Access'') : le cache était "désactivé" quand cette entrée était à 1. C'est un peu une sorte de ''chip select'' pour le 82385, limitée aux accès mémoire. Cette entrée permettait de programmer des intervalles d'adresse auxquels ne pas répondre, en utilisant des circuits de décodage d'adresse adaptés. Il avait aussi une entrée X16, qui permettait d'identifier les accès soit à un composant 16 bits. De tels accès ne doivent pas être mis en cache, sans doute parce que cela ne collait pas avec la taille des lignes de cache (32 bits). Et cette entrée permettait d'inhiber ces accès 16 bits d'agir sur le cache, en utilisant le bit du bus de commande adéquat. Le 82385 pouvait être intégré dans un système à deux processeurs, voire plus. Pour cela, chaque processeur avait son propre 82385 et sa SRAM rien qu'à lui. Il n'y avait pas de cache partagé entre les deux processeurs. Par contre, les deux caches étaient reliés au même bus système. Pour qu'ils ne se marchent pas sur les pieds, il y avait des circuits d'arbitrage pour gérer l'accès au bus. Un des deux 82385 était mis en mode maitre, l'autre était en mode esclave. Le 82385 maitre pouvait prendre le contrôle du bus, le 82385 esclave devait demander l'autorisation au premier pour accéder au bus système. Le 82385 gérait une forme limitée de cohérence des caches par invalidation. Dès que le 82385 détectait une prise de contrôle du bus par autre chose que le processeur, il surveillait les adresses transmises sur le bus. En cas de succès de cache, la ligne de cache associée était invalidée. Au-delà de ca, le 82385 avait une entrée FLUSH, qui ordonnait une invalidation totale du cache. Si cette entrée est mise à 1, toutes les lignes de cache sont invalidées. Les ''tags'' sont marqués comme invalides, mais les lignes de cache elles-mêmes ne sont pas touchées. ===L'exemple des processeurs Intel de microarchitecture ''Broadwell''=== Un autre exemple est celui du cache L4 des processeurs Broadwell et de quelques processeurs séparés. Ces processeurs ont une organisation en ''chiplet'' où le processeur incorpore plusieurs puces séparées : une puce pour le processeur proprement dit, une puce nommée ''Crystal Well'' pour le cache L4, et une puce IO pour la communication avec la RAM et la carte mère. Le processeur incorporait un cache L4 de 128 mébioctets, composé de mémoire eDRAM, qui était dispersé entre ''Crystal Well'' et les autres puces. Les données du cache L4 étaient dans ''Crystal Well'', alors que les Tags étaient soit dans le processeur lui-même, soit dans la puce IO ! La puce ''Crystal Well'' était une mémoire DRAM adressable tout ce qu'il y a de plus basique, avec cependant quelques optimisations notables. Par exemple, elle avait deux bus séparés pour l'écriture et la lecture. De plus, elle avait une organisation interne avec 128 banques, contre moins d'une dizaine pour la DDR de l'époque et environ 32 banques pour la DDR5 moderne. Elle contenait aussi quelques circuits pour gérer son rôle de mémoire cache, mais rien en ce qui concerne la gestion des tags eux-mêmes. Sur les processeurs de microarchitecture ''Broadwell'', les tags étaient placés dans le CPU et précisément dans le cache L3. À chaque accès mémoire au cache L3, les tags du cache L4 étaient consultés en parallèle. De fait, l'accès au cache L4 était assez rapide, malgré le fait que les données étaient dans une puce à part. Ajoutons à cela que le processeur et ''Crystal Well'' n'avaient pas la même finesse de gravure ni la même technologie de fabrication. Les tags étaient implémentés avec de la SRAM contre la DRAM pour les données, ce qui fait que la consultation des tags était plus rapide que l'accès aux données. Par la suite, dans certains CPU de microarchitecture ''skylake'', les tags ont été déplacés en-dehors du processeur pour finir dans le contrôleur mémoire. En faisant cela, le cache L4 pouvait être utilisé par autre chose que le processeur, et notamment par la carte graphique intégrée au CPU. Avec ''broadwell'', le fait que les tags étaient consultés en cas d'accès au L3 empêchait au GPU intégré de consulter le cache L4. Mais en déplaçant les tags dans le contrôleur mémoire, ce n'est plus le cas vu que la carte graphique a aussi accès au bus mémoire. Par contre, le temps d'accès augmente comparé à la solution précédente. On n'accède pas aux tags du L4 en parallèle du L3 : à la place, il faut consulter les tags du L3, détecter un défaut de cache L3, et ensuite accèder aux tags. ===Les caches RAM-configurables=== Un autre avantage des caches splittés est qu'on peut les modifier pour servir à la fois de mémoire cache, mais aussi de ''local store'', de mémoire RAM de petite taille. Le fonctionnement est assez simple à comprendre. Lors d'un accès au cache, on accède aux tags, puis à la RAM interne au cache. Lors d'un accès au ''local store'', on contourne l'accès au tags et on accède à la RAM interne au cache directement. Il s'agit de la technique du '''cache RAM-configurable''. L'usage de cache RAM-configurable est fréquent sur les cartes graphiques récentes, qui incorporent un ou plusieurs processeurs multicoeurs, dont le cache L1 de données est un cache RAM-configurable. [[File:Hydride cache - local store.png|centre|vignette|upright=2.0|Hydride cache - local store]] ===La compression de cache=== Une autre optimisation permise par les ''phased caches'' est l'implémentation de techniques de '''compression de cache''', qui visent à compresser des lignes de cache. L'intérêt est qu'on peut stocker plus de données dans le cache, à capacité égale. L'inconvénient est qu'on doit compresser/décompresser les lignes de cache, ce qui demande un circuit en plus et allonge les temps d'accès. En effet, le temps mis pour compresser/décompresser une ligne de cache s'ajoute au temps d'accès. Aussi, la compression de cache sert surtout pour les caches de bas niveau dans la hiérarchie mémoire, les gros caches aux temps d'accès assez longs. Une première technique, assez simple à implémenter et peu couteuse en circuit, est celle de la '''compression des lignes de cache nulles'''. Elle compresse uniquement les lignes de cache qui ne contiennent que des zéros. L'idée est qu'on ajoute, dans la mémoire des tags, un bit de contrôle pour chaque ligne de cache appelé le bit ''null''. Il indique si la ligne de cache ne contient que des zéros. Quand on lit une ligne de cache, la mémoire des tags est accédée et on vérifie le bit ''null'' : s'il vaut 1, on n'accède pas à la mémoire cache de données et un multiplexeur envoie un zéro sur le port de lecture. Le bit ''null'' est fixé lors de l'écriture d'une ligne de cache : elle passe dans un comparateur avec zéro relié à la mémoire des tags. La comparaison avec zéro peut se faire en parallèle de l'écriture ou avant (dans ce cas, on n'écrit pas la ligne de cache dans le cache). Les autres techniques de compression de cache permettent de compresser autre chose que des lignes de cache nulles. L'idée est qu'une ligne de cache physique peut par moment mémoriser plusieurs lignes de caches compressées. Par exemple, prenons un cache dont les lignes de cache font 64 octets. Il est possible de compresser deux lignes de cache pour qu'elles fassent chacune 32 octets, et les stocker dans une seule ligne de cache. Les deux lignes de cache auront des tags différents, mais pointeront sur la même ligne de cache physique. Et cela demande d'utiliser un ''phased cache'' dont la mémoire pour les tags est plus grande que la mémoire pour les données. Il n'y a donc plus une bijection entre tags et ligne de cache, mais une relation surjective. Chose qui n'est possible qu'avec un ''phased cache''. De plus, des bits de contrôles associés à chaque ''tag'' indiquent où se trouvent les lignes de cache compressées dans la ligne de cache : est-ce que c'est les 32 octets de poids fort ou de poids faible ? [[File:Compression de cache.png|centre|vignette|upright=2|Compression de cache]] Il ne semble pas que les techniques de compression de cache soient implémentées sur les processeurs modernes. Aucun n'utilise de compression de cache, à ma connaissance. Il faut dire que les techniques connues sont de mauvais compromis : le temps d'accès du cache augmente beaucoup, le cout en circuit pourrait être utilisé pour un cache non-compressé mais plus grand. Et notons que la compression de cache ne marche que si les données peuvent se compresser. Si ce n'est pas le cas, une partie de la mémoire des tags est inutilisée. Une revue de la littérature académique sur la compression de cache est disponible via ce lien, pour les curieux : * [https://inria.hal.science/hal-03285041 Understanding Cache Compression, par Carvalho et Seznec]. ==L'adressage physique ou logique des caches== Le cache utilise les adresses à lire/écrire pour déterminer s'il a une copie de la donnée en son sein. Mais l’interaction entre caches et mémoire virtuelle donne lieu à un petit problème : l'adresse utilisée est-elle une adresse virtuelle/logique ou physique ? La réponse varie suivant le processeur : certains caches utilisent l'adresse virtuelle, tandis que d'autres prennent l'adresse physique. On parle de cache '''virtuellement tagué''' dans le premier cas et de cache '''physiquement tagué''' dans le second. {| |[[File:Cache tagué virtuellement.png|vignette|Cache tagué virtuellement.]] |[[File:Cache tagué physiquement.png|vignette|Cache tagué physiquement.]] |} ===L'accès à un cache physiquement/virtuellement tagué=== La manière d'accéder à un cache dépend de s'il est virtuellement ou physiquement tagué. Il faut utiliser l'adresse virtuelle pour les premiers, physique pour les seconds. Avec un cache virtuellement tagué, l'adresse logique peut être envoyée directement au cache. La MMU ne traduit les adresses que s'il faut accéder à la mémoire RAM. Ces caches sont donc plus rapides. Avec un cache physiquement tagué, le processeur doit traduire l'adresse logique en adresse physique dans la MMU, avant d'accéder au cache. La traduction d'adresse se fait soit en accédant à une table des pages en mémoire RAM, soit en accédant à un cache spécifiquement dédié à accélérer la traduction d'adresse, la TLB (''Translation Lookaside Buffer''). Dans la quasi-totalité des cas, la traduction d'adresse passe par la TLB, ce qui fait qu'elle est raisonnablement rapide. Toujours est-il que chaque accès au cache demande d'accéder à la TLB et de faire la traduction d'adresse avant d'accéder au cache. L'accès est donc plus lent que sur les caches virtuellement tagués, où les accès sont plus directs. [[File:Virtual and Physical addressing.svg|centre|vignette|upright=2|Cache tagué virtuellement versus physiquement tagué.]] ===Les défauts des caches virtuellement tagués=== Les caches physiquement tagués sont moins rapides que les caches virtuellement adressés. Pourtant, les caches virtuellement tagués sont peu fréquents sur les processeurs modernes. Et la raison est assez intéressante : c'est une question d'adresses homonymes et synonymes. ====Les droits d'accès doivent être vérifiés lors d'un accès au cache==== Un premier problème est que la protection mémoire est compliquée avec de tels caches. Rappelons que certaines portions de mémoire sont accessibles seulement en lecture, ou sont interdites en écriture, sont inexécutables, etc. Ces droits d'accès sont gérés par la MMU, qui vérifie pour chaque accès mémoire que l'accès est autorisé. En bypassant la MMU, l'accès au cache virtuellement tagué ne permet pas de faire ces vérifications. Il est possible de charger une donnée en lecture seule dans le cache, mais d'y faire des accès en écriture pour les accès ultérieurs. Les solutions à cela sont multiples. La première consiste à consulter la MMU en parallèle de l'accès au cache. L'accès au cache est alors réalisé de manière spéculative, et est ensuite confirmé/annulé une fois que la MMU a rendu son verdict. Les performances du cache restent alors les mêmes : l'accès à la MMU se fait en parallèle de l'accès au cache, pas avant. Une autre solution est d'ajouter les droits d'accès en question dans la ligne de cache, dans les bits de contrôle situés après le Tag. Chaque accès au cache récupère ces bits de contrôle et vérifie si l'accès est autorisé. L'inconvénient est que les lignes de cache deviennent plus longues, les droits d'accès sont dupliqués entre MMU et cache. Mais si le budget en transistor suit, ce n'est rien d'insurmontable. ====Les adresses homonymes perturbent la gestion du cache==== Pour rappel, une adresse logique homonyme correspond à plusieurs adresses physiques différentes. Elles surviennent quand chaque programme a son propre espace d'adressage. Dans ce cas, une adresse logique correspondra à une adresse physique différente par programme.Une autre manière de voir les choses est qu'il y a en réalité deux adresses homonymes, qui ont la même valeur, mais appartiennent à des espaces d'adressage différentes. Et c'est cette seconde interprétation que nous allons utiliser. Les caches doivent gérer ces adresses homonymes et faire en sorte que la lecture/écriture d'une adresse homonyme se fasse à la bonne adresse physique, dans la bonne ligne de cache. Et autant un cache physiquement tagué n'a aucun problème avec ça, vu qu'il ne gère que des adresses physiques, autant des problèmes surviennent avec les caches virtuellement tagués. Le problème est que les caches virtuellement tagués doivent faire la différence entre deux adresses homonymes de même valeur. Pour corriger ces problèmes, il existe deux grandes méthodes. La première méthode est simple : '''vider les caches''' en changeant de programme. Leur contenu est rapatrié en mémoire RAM, puis les caches sont remis à zéro. Le vidage du cache recopie les lignes de cache ''dirty'' (modifiées) en RAM, puis efface/invalide tout le cache. C'est à cela que servent les instructions ''clean'' et d'invalidation vues plus haut, elles ont été inventées pour cette situation précise. Lorsque le système d'exploitation déclenche une commutation de contexte, à savoir qu'il change le programme en cours d'exécution, le processeur vide tous les caches du processeur. Les interruptions font la même chose, elles vide tous les caches du processeur. Une seconde méthode numérote chaque programme en cours d'exécution, chaque processus. Le numéro attribué est spécifique à chaque processus, ce qui fait qu'il est appelé un '''identifiant de processus CPU'''. Le processeur mémorise l'identifiant du programme en cours d'exécution dans un registre dédié. L'identifiant de processus CPU est utilisé lors des accès mémoire. Chaque ligne de cache contient le numéro de l'espace d'adressage associé, dans son ''tag''. Lors de chaque accès mémoire, l'ID du registre est comparé à l'ID de la ligne de cache accédée, pour vérifier que l'accès mémoire accède à la bonne donnée. Cette méthode n'est pas très économe en termes de transistors. L'usage d'identifiant de processus CPU est clairement meilleure en termes de performance, les commutations de contexte sont plus rapides. Par contre, le budget en transistor est plus important. Un autre défaut de cette méthode est que l'identifiant de processus est généralement codé sur une dizaine de bits, alors que le système d'exploitation utilise des identifiants de processus beaucoup plus larges, de 32 à 64 bits sur les CPU 32/64 bits. L'OS doit gérer la correspondance entre identifiants de processus CPU et ceux de l'OS. Parfois, pour cette raison, les OS n'utilisent pas toujours ce système d'identifiant de processus CPU. ====Les adresses synonymes perturbent aussi la gestion du cache==== La gestion des adresses synonymes est aussi un gros problème sur les caches virtuellement tagués. Pour rappel, il s'agit du cas où des adresses logiques différentes pointent vers la même adresse physique. Typiquement, quand deux programmes se partagent un morceau de mémoire, ce morceau correspondra à des adresses synonymes dans les deux espaces d'adressage. Mais il arrive que l'on ait des adresses synonymes dans le même espace d'adressage, ce n'est pas si rare ! Autant les adresses synonymes ne posent aucun problème avec les caches physiquement tagués, ce n'est pas le cas avec les caches virtuellement adressés. Sur ces caches, deux adresses logiques synonymes vont tomber dans deux lignes de cache différentes. Corriger ce problème demande d'ajouter des circuits annexes pour détecter les adresses synonymes, qui sont vraiment complexes et ont un cout en termes de performance. Aussi, les caches virtuellement tagués sont très peu utilisés sur les processeurs modernes. ===Les caches virtuellement adressés, mais physiquement tagués=== Si les caches physiquement et virtuellement tagués ont des défauts, il existe un intermédiaire qui est un bon compromis entre ces deux extrêmes. Il s'agit des '''caches virtuellement adressés - physiquement tagués''', aussi appelés '''caches pseudo-virtuels'''. Pour comprendre comment ils fonctionnent, précisons que ces caches sont soit des caches ''direct-mapped'', soit des caches associatifs par voie (composés de plusieurs RAM ''direct-mapped'' accédées en parallèle, plusieurs voies). L'accès à ce genre de cache se fait en deux temps : on accède à un ou plusieurs RAM ''direct-mapped'' et on vérifie ensuite les ''Tags'' pour sélectionner la bonne voie. Sur les caches ''direct-mapped'', on n'a qu'une seule RAM ''direct-mapped''. Sur les caches associatifs, on a plusieurs RAM ''direct-mapped'', appelées des voies, qui sont accédées en parallèle. L'accès se fait donc en deux étapes : adresser les RAM ''direct-mapped'' avec un indice, vérifier les ''tags'' avec le reste de l'adresse. Une autre chose à rappeler est que l'adresse logique est composée de deux parties : un numéro de page logique qui indique dans quel page se situe l'adresse, un décalage/''offset'' qui indique la position de l'adresse dans la page. La traduction d'adresse transforme le numéro de page logique en numéro de page physique, mais laisse le décalage intouché. L'idée est d'utiliser le décalage pour adresser les RAM avec le décalage, tandis que le numéro de page sert de ''tag''. Le décalage est découpé en deux lors de l'accès au cache : les bits de poids fort forment l'indice (l'adresse envoyée à la voie), les bits de poids faible donnent la position de l'adresse dans la ligne de cache. L'idée est d'utiliser un numéro de page physique pour les ''tags'', mais d'adresser les voies avec le décalage logique. Les deux servent à des instants différents : vérification des ''tags'' pour l'adresse physique, accès aux voies pour l'adresse logique. Ainsi, le problème des adresses synonymes ou homonymes est résolu par l'utilisation de l'adresse physique pour les tags. Par contre, l'accès au cache est plus rapide, car on utilise l'adresse logique pour la première étape. Le processeur accède à la TLB et récupère l'adresse physique pendant que l'on adresse les voies, les deux sont faits en parallèle, ce qui fait que tout se passe comme si l'accès à la TLB était gratuit. La TLB étant assez rapide comparé au cache, l'adresse physique est disponible quand on doit faire la comparaison avec les ''tags''. [[File:Virtual - Physical - Pseudo Virtual addressing.svg|centre|vignette|upright=2|Adressage pseudo virtuel des caches.]] Il s'agit d'un excellent compromis entre performance et correction des problèmes des adresses synonymes/homonymes. Tous les caches des processeurs haute performance utilisent cette méthode, au moins pour leurs caches L1. Les caches L2 tendent à utiliser des caches physiquement adressés, pour lesquels la latence d'accès est suffisante pour qu'on accède à la TLB en amont. La raison est assez simple à expliquer, elle provient d'une contrainte assez précise sur le calcul de l'indice. La conséquence est qu'un cache ''direct-mapped'' ne peut pas dépasser la taille d'une page, soit 4 kibioctets sur les ordinateurs actuels. Sur les caches associatifs, on peut dépasser cette limite en augmentant le nombre de voies, mais la taille maximale d'une voie reste celle d'une page. Cette contrainte n'est pas trop grave sur les caches de petite taille, dont les caches L1. La plupart d'entre eux ont trouvé un compromis idéal avec moins d'une dizaine de voies par cache, chacun de 4 kibioctets, ce qui donne des caches allant de 16 à 64 kibioctets, soit entre 4 et 16 voies. Par contre, un cache de grande taille doit utiliser un grand nombre de voies, ce qui est peu pratique. Aussi, cette technique de caches pseudo-virtuels n'est pas toujours appliquée sur les caches L2, qui sont physiquement adressés. Il faut dire qu'on accède au cache L2 lors d'un défaut dans le cache L1, et l'adresse physique est disponible à ce moment-là, elle a déjà été récupérée lors de l'accès au cache L1. On peut donc l'utiliser pour adresser le cache L2 sans perte de performance. ==Le remplacement des lignes de cache== Lorsqu'un cache est rempli et qu'on charge une nouvelle donnée dedans, il faut faire de la place pour cette dernière. Dans le cas d'un cache directement adressé, il n'y a rien à faire vu que la ligne de cache à évincer est déterminée lors de la conception du cache. Mais pour les autres caches, la donnée peut aller dans n'importe quelle ligne ou voie. Or, le choix des données à rapatrier en RAM doit être le plus judicieux possible : on doit virer de préférence des données inutiles. Rapatrier une donnée qui sera surement utilisée sous peu est inutile, et il vaudrait mieux supprimer des données qui ne serviront plus ou alors dans longtemps. Il existe différents algorithmes spécialement dédiés à résoudre ce problème efficacement, directement câblés dans les unités de gestion du cache. Certains sont vraiment très complexes, aussi je vais vous présenter quelques algorithmes particulièrement simples. Mais avant de voir ces algorithmes, il faut absolument que je vous parle d'une chose très importante. Quel que soit l'algorithme en question, il choisit la ligne de cache à évincer et recopie son contenu dans la RAM. Ce qui demande d'identifier et de sélectionner une ligne de cache parmi toutes les autres. Pour cela, le circuit de remplacement attribue une adresse chaque ligne de cache ! Vous avez bien vu : chaque ligne de cache est numérotée par une adresse, interne au cache. ===Le remplacement aléatoire=== Premier algorithme : la donnée effacée du cache est choisie au hasard ! C'est contre-intuitif, mais cet algorithme donne des résultats assez honorables, en plus d'utiliser très peu de portes logiques (un générateur de nombres pseudo-aléatoire est un circuit assez simple). Généralement, les défauts de cache sont séparés par un nombre assez important et irrégulier de cycles d'horloge. Dans ces conditions, cette technique donne un bon résultat. ===FIFO : first in, first out=== Avec l'algorithme FIFO, la donnée effacée du cache est la plus ancienne, celle chargée dans le cache avant les autres. Cet algorithme est très simple à implémenter en circuit, concevoir une mémoire de type FIFO n'étant pas très compliqué, comme on l’a vu dans le chapitre dédié à ce type de mémoires. Et on peut dire que dans le cas d'un cache, l'implémentation est encore plus simple et se contente d'un seul registre/compteur. Typiquement, il suffit d'ajouter un registre qui mémorise où se situe la donnée la plus récente. Toute insertion d'une nouvelle donnée se fait à l'adresse suivante, ce qui demande juste d'incrémenter le registre avant d'utiliser son contenu pour l'accès mémoire. [[File:Algorithme FIFO de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme FIFO de remplacement des lignes de cache.]] Cet algorithme possède une petite particularité sur les caches associatifs par voie : en augmentant le nombre d'ensembles, les performances peuvent se dégrader : c'est ce qu'on appelle l''''anomalie de Bélády'''. ===MRU : most recently used=== Avec l'algorithme MRU, la donnée remplacée est celle qui a été utilisée le plus récemment. Cet algorithme s'implémente simplement avec un registre, dans lequel on place le numéro de la dernière ligne de cache utilisée. Cet algorithme de remplacement est très utile quand un programme traverse des tableaux du premier élément jusqu'au dernier : les données du tableau sont rarement réutilisées, rendant le cache inutile. Il est prouvé que dans ces conditions, l'algorithme MRU est optimal. Mais dans toutes les autres conditions, cet algorithme a des performances assez misérables. ===LFU : least frequently used=== Avec l'algorithme LFU, la donnée supprimée est celle qui est utilisée le moins fréquemment. Cet algorithme s'implémente en associant un compteur à chaque ligne de cache, qui est incrémenté à chaque accès mémoire. La ligne la moins récemment utilisée est celle dont le compteur associé a la plus petite valeur. Implémenter cet algorithme prend pas mal de transistors, car il faut rajouter autant de compteurs qu'il y a de lignes de cache, en plus d'un circuit pour comparer les compteurs et d'un encodeur. [[File:Algorithme LFU de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme LFU de remplacement des lignes de cache]] ===LRU : least recently used=== Avec l'algorithme LRU, la donnée remplacée est celle qui a été utilisée le moins récemment. Cet algorithme se base sur le principe de localité temporelle, qui stipule qu'une donnée accédée récemment a de fortes chances d'être réutilisée dans un futur proche. Et inversement, la donnée la moins récemment utilisée du cache est celle qui a le plus de chance de ne servir à rien dans le futur. Autant la supprimer en priorité pour faire de la place à des données potentiellement utiles. Implémenter l'algorithme LRU peut se faire de différentes manières, qui ont pour point commun d'enregistrer les accès au cache pour en déduire la ligne la moins récemment accédée. La manière la plus simple demande d'utiliser un compteur pour chaque ligne de mémoire cache, un peu comme le LFU. La différence avec le LFU est que le compteur n'est pas incrémenté lors d'un accès mémoire. À la place, ce compteur est incrémenté régulièrement, chaque incrémentation ayant lieu en même temps pour tous les compteurs. Quand un bloc est chargé dans le cache, ce compteur est mis à zéro. Quand une ligne de cache doit être remplacée, un circuit va vérifier la valeur de tous les compteurs : la ligne LRU (la moins récemment utilisée), est celle dont le compteur a la valeur la plus haute. Le circuit est composé d'un paquet de comparateurs, et d'un encodeur, comme pour l'agorithme LFU. ===Les approximations du LRU=== Implémenter le LRU demande un nombre de transistors proportionnel au carré du nombre de lignes de cache. Autant dire que le LRU devient impraticable sur de gros caches. Ce qui fait que les processeurs modernes implémentent des variantes du LRU, moins couteuses en transistors, qui donnent un résultat approximativement semblable au LRU. En clair, ils ne sélectionnent pas toujours la ligne de cache la moins récemment utilisée, mais une ligne de cache parmi les moins récemment utilisées. Ce n'est pas un problème si grave que cela car les lignes les moins récemment utilisées ont toutes assez peu de chance d'être utilisées dans le futur. Entre choisir de remplacer une ligne qui a 0,5 % de chances d'être utilisée dans le futur et une autre qui a une chance de seulement 1 %, la différence est négligeable en termes de taux de succès. Mais les gains en termes de circuits ou de temps d'accès au cache de ces algorithmes sont très intéressants. L'algorithme le plus simple consiste à couper le cache (ou chaque voie s'il est associatif) en plusieurs sections. L'algorithme détermine la section la moins récemment utilisée, avant de choisir aléatoirement une ligne de cache dans cette section. Pour implémenter cet algorithme, il nous suffit d'un registre qui mémorise le morceau le moins récemment utilisé, et d'un circuit qui choisit aléatoirement une ligne de cache. Cette technique s'adapte particulièrement bien avec des caches associatifs à voies : il suffit d'utiliser autant de morceaux que de voies. Autre algorithme, un peu plus efficace : le '''pseudo-LRU de type M'''. Cet algorithme attribue un bit à chaque ligne de cache, bit qui sert à indiquer de façon approximative si la ligne de cache associée est une candidate pour un remplacement ou non. Il vaut 1 si la ligne n'est pas une candidate pour un remplacement et zéro sinon. Le bit est mis à 1 lorsque la ligne de cache associée est lue ou écrite. Évidemment, au fil du temps, toutes les lignes du cache finiront par avoir leur bit à 1. Lorsque cela arrive, l'algorithme remet tous les bits à zéro, sauf pour la dernière ligne de cache accédée. L'idée derrière cet algorithme est d'encercler la ligne de cache la moins récemment utilisée au fur et à mesure des accès. L'encerclement commence lorsque l'on remet tous les bits associés aux lignes de cache à 0, sauf pour la ligne accédée en dernier. Au fur et à mesure des accès, l'étau se resserre autour de la ligne de cache la moins récemment utilisée. Après un nombre suffisant d'accès, l'algorithme donne une estimation particulièrement fiable. Et comme les remplacements de lignes de cache sont rares comparés aux accès aux lignes, cet algorithme finit par donner une bonne estimation avant qu'on ait besoin d'effectuer un remplacement. Le dernier algorithme d'approximation, le '''PLURt''', se base sur ce qu'on appelle un arbre de décision. Il a besoin de n − 1 bits pour déterminer la ligne LRU. Ces bits doivent être organisés en arbre, comme illustré plus bas. Chacun de ces bits sert à dire : le LRU est à ma droite ou à ma gauche : il est à gauche si je vaux 0, et à droite si je vaux 1. Trouver le LRU se fait en traversant cet arbre, et en interprétant les bits un par un. Au fur et à mesure des lectures, les bits sont mis à jour dans cet arbre, et pointent plus ou moins bien sur le LRU. La mise à jour des bits s'effectue lors des lectures et écritures : quand une ligne est lue ou écrite, elle n'est pas la ligne LRU. Pour l'indiquer, les bits à 1 qui pointent vers la ligne de cache sont mis à 0 lors de la lecture ou écriture. {| |[[File:Organisation des bits avec l'algorithme PLURt.jpg|vignette|Organisation des bits avec l'algorithme PLURt.]] |[[File:Ligne de cache pointée par les bits de l'algorithme.png|vignette|Ligne de cache pointée par les bits de l'algorithme.]] |} ===LRU amélioré=== L'algorithme LRU, ainsi que ses variantes approximatives, sont très efficaces tant que le programme respecte relativement bien la localité temporelle. Par contre, Le LRU se comporte assez mal dans les circonstances ou la localité temporelle est mauvaise mais où la localité spatiale est respectée, le cas le plus emblématique étant le parcours d'un tableau. Pour résoudre ce problème, des variantes du LRU existent. Une variante très connue, l''''algorithme 2Q''', utilise deux caches : un cache FIFO pour les données accédées une seule fois et un second cache LRU. Évidemment, les données lues une seconde fois sont migrées du cache FIFO vers le cache LRU, ce qui n'est pas très pratique. Les processeurs n'utilisent donc pas cette technique, mais celle-ci est utilisée dans les caches de disque dur. D'autres variantes du LRU combinent plusieurs algorithmes à la fois et vont choisir lequel de ces algorithmes est le plus adapté à la situation. Notre cache pourra ainsi détecter s’il vaut mieux utiliser du MRU, du LRU, ou du LFU suivant la situation. ==Les écritures dans le cache : gestion et optimisations== Les écritures se font à une adresse mémoire bien précise, qui peut ou non être chargée dans le cache. Si la donnée à écrire est chargée dans le cache, elle est modifiée directement dans le cache, mais elle ne l'est pas forcément en mémoire RAM. Suivant le processeur, les écritures sont ou non propagées en mémoire RAM. Il existe deux stratégies d'écritures, appelées respectivement le ''write-back'' et le ''write-through''. Avec un cache ''write-back'', si la donnée à mettre à jour est présente dans le cache, on écrit dans celui-ci sans écrire dans la mémoire RAM. Dans ces conditions, une donnée n'est enregistrée en mémoire que si celle-ci quitte le cache, ce qui évite de nombreuses écritures mémoires inutiles. [[File:Cache write-through.png|centre|vignette|upright=2|Cache write-through.]] Avec les caches '''Write-Through''', toute écriture dans le cache est propagée en RAM. Cette stratégie augmente le nombre d'écritures dans la mémoire RAM, ce qui peut saturer le bus reliant le processeur à la mémoire. Les performances de ces caches sont donc légèrement moins bonnes que pour les caches ''write back''. Par contre, ils sont utiles dans les architectures avec plusieurs processeurs, comme nous le verrons dans les chapitres sur les architectures multiprocesseurs. [[File:Cache write-back.png|centre|vignette|upright=2|Cache write-back.]] ===Les caches ''Write-through''=== Sans optimisation particulière, on ne peut écrire dans un cache ''write-through'' pendant qu'une écriture en RAM a lieu en même temps : cela forcerait à effectuer deux écritures simultanées, en comptant celle imposée par l'écriture dans le cache. Pour éviter cela, certains caches ''write-through'' intègrent un '''tampon d’écriture''', qui sert de file d'attente pour les écritures en RAM. C'est une mémoire FIFO dans laquelle on place temporairement les données à écrire en RAM, où elles attendent en attendant que la RAM soit libre. Grâce à lui, le processeur peut écrire dans un cache même si d'autres écritures sont en attente dans le tampon d'écriture. Par souci d'efficacité, des écritures à la même adresse en attente dans le tampon d’écriture sont fusionnées en une seule. Cela fait un peu de place dans le tampon d’écriture, et lui permet d'accumuler plus d'écritures avant de devoir bloquer le cache. Il est aussi possible de fusionner des écritures à adresses consécutives de la mémoire en une seule écriture en rafales. Dans les deux cas, on parle de '''combinaison d'écriture'''. Mais la technique du tampon d'écriture a cependant un léger défaut qui se manifeste dans une situation bien précise : quand le processeur veut lire une donnée en attente dans le tampon d’écriture. La première manière de gérer cette situation est de mettre en attente la lecture tant que la donnée n'a pas été écrite en mémoire RAM. On peut aussi lire la donnée directement dans le tampon d'écriture, cette optimisation portant le nom de '''''store-to-load forwading'''''. Dans tous les cas, il faut détecter le cas où une lecture accède à une donnée dans le tampon d'écriture. À chaque lecture, l'adresse à lire est envoyée au tampon d'écriture, qui vérifie si une écriture en attente se fait à cette adresse. Pour cela, le tampon d’écriture doit être un cache, dont chaque entrée mémorise une écriture. Chaque ligne de cache contient la donnée à écrire, et le tag de la ligne de cache contient l'adresse où écrire la donnée. Notons que cache d'écriture a une politique de remplacement de type FIFO, le tampon d'écriture non-optimisé étant une mémoire FIFO. ===Les caches ''Write-back''=== Les caches ''write-back'' ont beau avoir des performances supérieures à celles des caches ''write-through'', il existe des optimisations qui permettent d'améliorer leurs performances. Ces optimisations consistent à ajouter des caches spécialisés à côté du cache proprement dit. Ces caches permettent de mémoriser des données qui sont éliminées du cache par les algorithmes de remplacement de ligne cache, sans pour autant faire une écriture en RAM. En suivant la procédure habituelle de remplacement des lignes de cache, on doit rapatrier la ligne en RAM avant d'en charger une nouvelle. On peut améliorer la situation en faisant l'inverse : on charge la nouvelle ligne pendant que l'ancienne donnée est rapatriée en RAM. Ainsi, la nouvelle ligne est disponible plus tôt pour le processeur, diminuant son temps d'attente. Pour implémenter cette technique, on doit mémoriser l'ancienne ligne de cache temporairement dans un '''cache d’éviction''' (ou ''write-back buffer''). [[File:Cache d’éviction.png|centre|vignette|upright=2|Cache d’éviction]] Les caches directement adressés ou associatifs par voie possèdent aussi un tampon d’écriture amélioré. Pour limiter les défauts par conflit de ces caches, des scientifiques ont eu l'idée d'insérer un cache pour stocker les données virées du cache. En faisant ainsi, si une donnée est virée du cache, on peut alors la retrouver dans ce cache spécialisé. Ce cache s'appelle le '''cache de victime'''. Ce cache de victime est géré par un algorithme de suppression des lignes de cache de type FIFO. Petit détail : ce cache utilise un tag légèrement plus long que celui du cache directement adressé au-dessus de lui. L'index de la ligne de cache doit en effet être contenu dans le tag du cache de victime, pour bien distinguer deux adresses différentes, qui iraient dans la même ligne du cache juste au-dessus. [[File:Victim Cache Implementation Example.svg|centre|vignette|upright=1|Cache de victime.]] ===La configuration du fonctionnement du cache=== Sur de nombreux processeurs, il est possible de configurer la mémoire cache pour qu'elle fonctionne soit en mode ''write-back'', soit en mode ''write-through''. Pour cela, les processeurs modernes incorporent des '''registres de configuration du cache'''. Le terme ''registre de configuration du cache'' est assez transparent et indique bien quel est leur rôle. Ils configurent comment le cache est utilisé et permettent notamment de configurer le cache pour dire s'il doit fonctionner en mode ''write-back'' ou ''write-through''. Ils permettent aussi d'activer ou de désactiver la combinaison sur écriture. Les registres en question sont configurés soit par le BIOS, soit par le système d'exploitation. Ce sont des registres protégés, que les applications ne peuvent pas configurer, elles n'en ont pas le droit. Typiquement, ils ne sont accessibles en écriture qu'en mode noyau. Sur les processeurs x86, les registres de configuration du cache sont appelés des '''''Memory type range registers''''' (''MTRRs''). Les MTRRs sont assez nombreux, et il y a notamment une différence entre mode réel et protégé. Si vous vous souvenez des chapitres sur le mode d'adressage et la mémoire virtuelle, vous vous souvenez que les processeurs x86 incorporent plusieurs modes de fonctionnement. En mode réel, le processeur ne peut adresser qu'un mébioctet de RAM, avec un système de segmentation particulier. En mode protégé, le processeur peut adresser toute la mémoire et la segmentation fonctionne différemment, quand elle n'est pas simplement désactivée. Les MTRRs sont séparés en deux : ceux pour le mode réel, ceux pour le mode protégé. Les MTRRs fixes sont ceux qui configurent le cache en mode réel, ils étaient utilisés pour gérer l'accès au BIOS, à la mémoire VGA de la carte graphique, et quelques autres accès aux entrées-sorties basiques gérées nativement par le BIOS. Pour le mode protégé, les processeurs au-delà du 386 incorporent des MTRRs variables, qui servent pour les autres entrées-sorties en général, notamment les périphériques PCI, la mémoire vidéo de la carte graphique, et j'en passe. De nos jours, les registres de configuration du cache sont désuets et cette fonctionnalité est gérée directement par la mémoire virtuelle. La table des pages contient, pour chaque page mémoire, des bits de contrôle qui disent si la page mémoire est cacheable ou non. Le contournement de cache est alors géré par le système de mémoire virtuelle, le cache de TLB et tout ce qui va avec. ===L’allocation sur écriture=== Que faire quand une écriture modifie une donnée qui n'est pas dans le cache ? Doit-on écrire la donnée dans le cache, ou non ? Si la donnée est écrite dans le cache, on dit que le cache fait une '''allocation sur l'écriture''' (ou ''write-allocate''). Certains caches effectuent une telle allocation sur écriture, mais d'autres ne le font pas ou du moins pas systématiquement. L’allocation sur écriture peut se décliner en deux sous-catégories : le '''chargement à la demande''' et l''''écriture immédiate'''. Dans le premier cas, on charge la donnée à modifier dans le cache, et on la remplace avec la donnée écrite. Dans l'écriture immédiate, l'écriture a lieu directement dans le cache et la donnée à modifier n'est pas chargée dans le cache. Évidemment, seule une portion de la ligne de cache contient la donnée écrite (valide), et le reste contient des données invalides. Le cache doit savoir quelles sont les portions du cache qui sont valides : cela demande d'utiliser un ''sector cache''. [[File:Write-back with write-allocation.svg|centre|vignette|upright=2|Cache Write-back avec allocation sur écriture.]] Sans allocation sur écriture, l'écriture est transférée directement aux niveaux de cache inférieurs ou à la mémoire si la donnée à modifier n'est pas dans le cache. Certains caches de ce genre utilisent une petite optimisation : lors de toute écriture, ils supposent que l'écriture donnera un succès de cache. Si c'est le cas, la ligne de cache qui contient la donnée est mise à jour avec la donnée à écrire. Mais si ce n'est pas le cas, la ligne de cache est invalidée, et l'écriture est transférée directement à la mémoire ou aux niveaux de cache inférieurs. [[File:Write-through with no-write-allocation.svg|centre|vignette|upright=2|Cache Write-through sans allocation sur écriture.]] ===La cohérence des caches=== Il arrive parfois que la mémoire d'un ordinateur soit mise à jour, sans que les modifications soient répercutées dans les mémoires cache. Dans ce cas, le cache contient une donnée périmée. Or, un processeur doit toujours éviter de se retrouver avec une donnée périmée et doit toujours avoir la valeur correcte dans ses caches : cela s'appelle la '''cohérence des caches'''. Il est possible de se retrouver avec des valeurs périmées dans le cache sur les ordinateurs avec plusieurs processeurs, ou si un périphérique écrit en RAM, les modifications ne sont pas répercutées automatiquement dans les mémoires cache. Pour résoudre ce problème, on peut interdire de charger dans le cache des données stockées dans les zones de la mémoire dédiées aux périphériques. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires cache. Autre solution : utiliser le fait que les périphériques déclenchent une interruption matérielle pour laisser le contrôleur DMA accéder à la mémoire. Dans ce cas, il suffit de vider les caches à chaque interruption matérielle. Le processeur peut le faire automatiquement, ou fournir des instructions pour. ==Le ''cache bypassing'' : contourner le cache== Dans certaines situations, le cache n'est pas utilisé pour certains accès mémoire. Diverses techniques permettent en effet d'effectuer des accès mémoire qui contournent le cache, qui ne passent pas par le cache. Ils sont utilisés quand l'accès en cache fait que des instructions normales ne fonctionnent pas. Par exemple, de tels accès directs à la RAM sont notamment utilisés pour l'implémentation d'instructions atomiques, une classe d'instructions spécifiques utilisées sur les processeurs multicœurs, dont nous parlerons dans plusieurs chapitres. Mais ils sont aussi utilisés pour l'accès aux périphériques, ce que nous allons voir maintenant. ===Accéder aux périphériques demande de contourner le cache=== Pour rappel, un périphérique (au sens d'entrée-sortie) contient des registres d’interfaçage qui ont une adresse au même titre que les cases mémoire. Un périphérique peut à tout instant modifier ses registres d’interfaçage, ce qui se répercute automatiquement dans l'espace d'adressage, mais rien de tout cela n'est transmis au cache. Si les accès aux périphériques passaient par l'intermédiaire du cache, on aurait droit à des problèmes. On aurait encore une fois droit à des problèmes de cohérence des caches. Le problème est géré différemment suivant que l'on utilise un espace d'adressage séparé ou des entrées-sorties mappées en mémoire. La solution est que les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache. Cela demande d'adapter le cache et le processeur. L'implémentation exacte dépend de comment sont adressés les périphériques. Pour rappel, il y a deux solutions pour adresser les périphériques : soit les périphériques disposent d'un espace d'adressage séparé de celui de la mémoire, soit il y un espace d'adressage unique partagé entre processeur et mémoire. Les deux cas donnent des solutions différentes. Avec un espace d'adressage séparé, l'espace d'adressage des périphériques n'est pas caché : aucun accès dans cet espace d'adressage ne passe par le cache. La mémoire cache n'est utilisée que pour l'espace d'adressage des mémoires, rien d'autre. C'est de loin le cas le plus simple : il suffit de concevoir le processeur pour. Il dispose d'instructions séparées pour les accès aux registres d’interfaçage et à la RAM/ROM, les premières ne passent pas par le cache, les autres si. Avec des entrées-sorties mappées en mémoire, la même solution est utilisée, mais dans une version un peu différente. Là encore, les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache, si on veut qu'ils marchent comme ils le doivent. Cela demande d'adapter le cache et le matériel pour que accès aux périphériques mappés en mémoire contournent le cache. Des adresses, voire des zones entières de la mémoire, sont marquées comme étant non-cachables. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires caches. Là encore, le processeur doit être prévu pour : on doit pouvoir le configurer de manière à marquer certaines zones de la RAM comme non-cacheable. Reste qu'il faut marquer des régions de la RAM comme non-cacheable. Pour cela, on améliore les registres de configuration du cache, vus plus haut, afin qu'ils permettent de configurer certaines portions de la RAM pour préciser qu'elles ne doivent pas être mises en cache, qu'il faut activer le contournement de cache pour celles-ci. ===Contourner le cache pour des raisons de performance=== Il arrive que des données avec une faible localité soient chargées dans le cache inutilement. Or, il vaut mieux que ces données transitent directement entre le processeur et la mémoire, sans passer par l'intermédiaire du cache. Pour cela, le processeur peut fournir des instructions d'accès mémoire qui ne passent pas par le cache, à côté d'instructions normales. De telle instructions sont appelées des '''instructions mémoire non-temporelles'''. Non-temporelle, dans le sens : pas de localité temporelle (c.a.d que les données ne seront pas réutilisées plus tard). Mais il existe aussi des techniques matérielles, où le cache détecte à l'exécution les lectures qui gagnent à contourner le cache. La dernière méthode demande d'identifier les instructions à l'origine des défauts de cache, le processeur accédant directement à la RAM quand une telle instruction est détectée. Si une instruction d'accès mémoire fait trop de défauts de cache, c'est signe qu'elle gagne à contourner le cache. L'idée est de mémoriser, pour chaque instruction d'accès mémoire, un historique de ses défauts de cache. Il existe plusieurs méthodes pour cela, mais toutes demandent d'ajouter de quoi mémoriser l'historique des défauts de cache des instructions. L'historique est mémorisé dans une mémoire appelée la '''table d’historique des défauts de lecture''' (''load miss history table''), qui est souvent un cache. L'historique en question est, dans sa version la plus simple, un compteur de quelques bits incrémenté à chaque succès de cache et décrémenté à chaque défaut de cache, qui indique si l'instruction a en moyenne fait plus de défauts ou de succès de cache. La table associe le ''program counter'' d'une instruction mémoire à cet historique. À la première exécution d'une instruction d'accès mémoire, une entrée de cette table est réservée pour l'instruction. Lors des accès ultérieurs, le processeur récupérer les informations associées et décide s'il faut contourner le cache ou non. ==La hiérarchie mémoire des caches== [[File:Cache Hierarchy.png|vignette|Hiérarchie de caches]] On pourrait croire qu'un seul cache est largement suffisant pour compenser la lenteur de la mémoire. Hélas, les processeurs sont devenus tellement rapides que les caches sont eux-mêmes très lents ! Pour rappel, plus une mémoire peut contenir de données, plus elle est lente. Et les caches ne sont pas épargnés. Si on devait utiliser un seul cache, celui-ci serait très gros et donc trop lent. La situation qu'on cherche à éviter avec la mémoire RAM revient de plus belle. Même problème, même solution : si on a décidé de diviser la mémoire principale en plusieurs mémoires de taille et de vitesse différentes, on peut bien faire la même chose avec la mémoire cache. Depuis environ une vingtaine d'années, un processeur contient plusieurs caches de capacités très différentes : les caches L1, L2 et parfois un cache L3. Certains de ces caches sont petits, mais très rapides : c'est ceux auxquels on va accéder en priorité. Viennent ensuite d'autres caches, de taille variable, mais plus lents. Les processeurs ont donc une hiérarchie de caches qui se fait de plus en plus complexe avec le temps. Cette hiérarchie est composée de plusieurs niveaux de cache, qui vont des niveaux inférieurs proches de la mémoire RAM à des niveaux supérieurs proches du processeur. Plus on monte vers les niveaux supérieurs, plus les caches sont petits et rapides. Un accès mémoire dans une hiérarchie de cache fonctionne comme suit : on commence par vérifier si la donnée recherchée est dans le cache le plus rapide, à savoir le cache L1. Si c'est le cas,n on la charge depuis ce cache directement. Si elle n’y est pas, on vérifie si elle est dans le cache de niveau supérieur, le cache L2. Et rebelote ! Si elle n'y est pas, on vérifie le cache du niveau supérieur. Et on répète cette opération, jusqu’à avoir vérifié tous les caches. Si la donnée n'est dans aucun cache, on doit alors aller chercher la donnée en mémoire. [[File:Hiérarchie de caches.png|centre|vignette|upright=2|Hiérarchie de caches]] Il y a des différences assez notables entre chaque niveau de cache. Par exemple, les différents niveaux de cache n'ont pas forcément les mêmes politiques de remplacement des lignes de cache. Le cache L1 a généralement une politique de remplacement simple, très rapide, mais peu efficace. De même, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Le cache le plus proche de la mémoire est appelé le '''cache de dernier niveau''', ''Last Level Cache'' en anglais. Il a parfois des caractéristiques totalement différentes des autres caches. Par exemple, sur les processeurs multicoeurs, le cache L3 n'est techniquement pas dans le cœur, mais fait partie d'un ensemble de circuits reliés, comme le contrôleur mémoire ou l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc. ===Les caches exclusifs et inclusifs=== Notons que du point de vue de cette vérification, il faut distinguer les caches inclusifs et exclusifs. Avec les caches inclusifs, si une donnée est présente dans un cache, alors elle est présente dans les caches des niveaux inférieurs, ce qui implique l'existence de données en doublon dans plusieurs niveaux de cache. À l'opposé, les caches exclusifs font que toute donnée est présente dans un seul cache, pas les autres. Il existe aussi des caches qui ne sont ni inclusifs, ni exclusifs. Sur ces caches, chaque niveau de cache gère lui-même ses données, sans se préoccuper du contenu des autres caches. Pas besoin de mettre à jour les niveaux de cache antérieurs en cas de mise à jour de son contenu, ou en cas d'éviction d'une ligne de cache. La conception de tels caches est bien plus simple. Dans les '''caches exclusifs''', le contenu d'un cache n'est pas recopié dans le cache de niveau inférieur. Il n'y a pas de donnée en double et on utilise 100 % de la capacité du cache, ce qui améliore le taux de succès. Par contre, le temps d'accès est un peu plus long. La raison est que si une donnée n'est pas dans le cache L1, on doit vérifier l'intégralité du cache L2, puis du cache L3. De plus, assurer qu'une donnée n'est présente que dans un seul cache nécessite aux différents niveaux de caches de communiquer entre eux pour garantir que l'on a pas de copies en trop d'une ligne de cache, ce qui peut prendre du temps. [[File:Caches exclusifs.png|centre|vignette|upright=2|Caches exclusifs]] Dans le cas des '''caches inclusifs''', le contenu d'un cache est recopié dans les caches de niveau inférieur. Par exemple, le cache L1 est recopié dans le cache L2 et éventuellement dans le cache L3. Ce genre de cache a un avantage : le temps d'accès à une donnée est plus faible. La raison est qu'il ne faut pas vérifier tout un cache, mais seulement la partie qui ne contient pas de donnée en doublon. Par exemple, si la donnée voulue n'est pas dans le cache L1, on n'est pas obligé de vérifier la partie du cache L2 qui contient la copie du L1. Ainsi, pas besoin de vérifier certaines portions du cache, ce qui est plus rapide et permet de simplifier les circuits de vérification. En contrepartie, l'inclusion fait que qu'une partie du cache contient des copies inutiles, comme si le cache était plus petit. De plus, maintenir l'inclusion est compliqué et demande des circuits en plus et/ou des échanges de données entre caches. [[File:Caches inclusifs.png|centre|vignette|upright=2|Caches inclusifs]] Maintenir l'inclusion demande de respecter des contraintes assez fortes, ce qui ne se fait pas facilement. Premièrement, toute donnée chargée dans un cache doit aussi l'être dans les caches de niveau inférieur. Ensuite, quand une donnée est présente dans un cache, elle doit être maintenue dans les niveaux de cache inférieurs. De plus, toute donnée effacée d'un cache doit être effacée des niveaux de cache supérieurs : si une donnée quitte le cache L2, elle doit être effacée du L1. Ces trois contraintes posent des problèmes si chaque cache décide du remplacement des lignes de cache en utilisant un algorithme comme LRU, LFU, MRU, ou autre, qui utilise l'historique des accès. En effet, dans ce cas, le cache décide de remplacer les lignes de cache selon l'historique des accès, historique qui varie suivant chaque niveau de cache. Par exemple, une donnée rarement utilisée dans le L2 peut parfaitement être très fréquemment utilisée dans le L1 : la donnée sera alors remplacée dans le L2, mais sera maintenue dans le L1. On observe aussi des problèmes quand il existe plusieurs caches à un seul niveau : chaque cache peut remplacer les lignes de cache d'une manière indépendante des autres caches du même niveau, donnant lieu au même type de problème. Pour maintenir l'inclusion, les caches doivent se transmettre des informations qui permettent de maintenir l'inclusion. Par exemple, les caches de niveaux inférieurs doivent prévenir les niveaux de cache supérieurs quand ils remplacent une ligne de cache. De plus, toute mise à jour dans un cache doit être répercutée dans les niveaux de cache inférieurs et/ou supérieurs. On doit donc transférer des informations de mise à jour entre les différents niveaux de cache. Généralement, le contenu des caches d'instruction n'est pas inclus dans les caches de niveau inférieurs, afin d'éviter que les instructions et les données se marchent sur les pieds. Enfin, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Dans ce cas, l'inclusion est plus difficile à maintenir, pour des raisons assez techniques. ===Les caches eDRAM, sur la carte mère et autres=== D'ordinaire, les mémoires caches sont intégrées au processeur, à savoir que cache et CPU sont dans le même circuit imprimé. Les caches sont donc fabriqués avec de la SRAM, seule forme de mémoire qu'on peut implémenter dans un circuit intégré. Intégrer tous les caches dans le processeur est une solution et efficace. Mais certains processeurs ont procédé autrement. [[File:Cache-on-a-stick module.jpg|vignette|Cache-on-a-stick module]] Des processeurs assez anciens incorporaient un cache L1 dans le processeur, mais plaçaient un cache L2 sur la carte mère. Le cache était clippé sur un connecteur sur la carte mère, un peu comme le sont les barrettes de mémoire. On parlait alors de '''''Cache on a stick''''' (COAST). On aurait pu s'attendre à ce que de tels caches soient en DRAM, vu qu'ils sont placés sur des barrettes de RAM, mais la ressemblance avec la mémoire RAM principale s'arrête là. Le cache était fabriqué en mémoire SRAM, même s'il est en théorie possible de faire de tels caches avec de la DRAM. Les premiers processeurs avec un cache faisaient ainsi, au début des années 90. Il a été introduiot sur les processeurs Motorola, et a été utilisé sur les IBM PC et les Macintosh de l'époque. Les ordinateurs Macintosh utilisaient de tels caches, pour la pluaprt des modèles. Pour ce qui est des PC, les premiers processeurs x86 faisaient pareil, notamment les processeurs Intel. Le 486, le Pentium et le Pentium 2 utilisaient des ''Cache on a stick''. L'avantage est que cela permettait de mettre plus de cache, à une époque où les circuits étaient limités en transistors. De plus, cela permettait au consommateur de choisir quelle quantité de cache il voulait, selon ses finances. Il était possible de laisser le processeur fonctionner soit sans mémoire cache, soit avec un cache de 256 Kibioctets, de 512 Kibioctets, etc. Il était possible d'upgrader le cache si besoin. Pour les CPU Intel, le cache était connecté sur le bus système, au même titre que la mémoire RAM et les entrées-sorties. Il faut dire que les processeurs de l'époque utilisaient un bus système et n'avaient pas de bus mémoire dédié. Mais en théorie, rien n’empêche de connecter le cache sur un bus mémoire dédié. Toujours est-il que les lectures et écritures étaient propagées à la fois dans le cache et la RAM. Les écritures se faisaient dans les deux, systématiquement dans la RAM, mais aussi dans le cache en cas de succès de cache. Les lectures étaient servies soit par le cache en cas de succès de cache, soit par la RAM en cas de défaut de cache. Si le cache répondait en premier, la transaction sur le bus se terminait précocement et l'accès en RAM était abandonné. [[File:Intel486 Иерархия памяти.png|centre|vignette|upright=2.5|Intel486 : le cache était connecté sur le bus système.]] À l'inverse, certains processeurs possédaient un cache fabriqué en mémoire DRAM, et plus précisément avec de la mémoire eDRAM. Le cache n'était pas intégré dans le même circuit imprimé que le processeur, mais profitait d'une architecture en ''chiplet''. Pour rappel, cela veut dire que le processeur est en réalité composé de plusieurs circuits intégré séparés, mais interconnectés et soudés sur un même PCB carré. Avec un cache en eDRAM, le cache avait son propre circuit intégré, séparé du circuit intégré du processeur ou du circuit intégré pour le contrôleur mémoire/IO. Un exemple est celui du cache des processeurs Intel de microarchitecture Broadwell, vus dans ce chapitre dans la section sur les caches splittés. Les tags étaient intégrés dans le circuit intégré du processeur, mais les données étaient mémorisées dans une puce d'eDRAM séparée. La puce eDRAM correspondait en réalité à une DRAM adressable qui servait de DRAM pour les données et mémorisaient les voies du cache. ==Les caches adressés par somme et hashés== Les caches adressés par somme sont optimisés pour incorporer certains calculs d'adresse directement dans le cache lui-même. Pour rappel, certains modes d'adressage impliquent un calcul d'adresse, qui ajoute une constante à une adresse de base. Généralement, l'adresse de base est l'adresse d'un tableau ou d'une structure, et la constante ajoutée indique la position de la donnée dans le tableau/la structure. Les caches hashés et les caches adressés par somme permettent de faire l'addition directement dans la mémoire cache. Voyons d'abord les caches hashés, avant de passer aux caches adressés par somme. Sur les '''caches hashés''', l'addition est remplacée par une autre opération, par exemple des opérations bit à bit du style XOR, AND ou OR, etc. Seulement, utiliser des opérations bit à bit pose un problème : il arrive que deux couples Adresse/décalage donnent le même résultat. Par exemple, le couple Adresse/décalage 11101111/0001 donnera la même adresse que le couple 11110000/0000. Dit autrement, deux adresses censées être différentes (après application du décalage) sont en réalité attribuées à la même ligne de cache. Il est toutefois possible de gérer ces situations, mais cela demande des astuces de haute volée pour faire fonctionner la mémoire cache correctement. Sur les '''caches adressés par somme''', le décodeur est modifié pour se passer de l'addition. Pour comprendre comment, il faut rappeler qu'un décodeur normal est composé de comparateurs, qui vérifient si l'entrée est égale à une constante bien précise. Sur un cache ordinaire, l'addition est faite séparément du décodage des adresses par le cache, dans l'unité de calcul ou dans l'unité de génération d'adresse. [[File:Non sum adressed cache.png|centre|vignette|upright=2|Cache normal.]] Mais les caches adressés par somme modifient le décodeur, qui est alors composé de comparateurs qui testent si la somme adresse + décalage est égale à une constante. [[File:Cache adressé par somme.png|centre|vignette|upright=2|Cache adressé par somme.]] Chaque circuit du décodeur fait le test suivant, avec K une constante qui dépend du circuit : : <math>A + B = K</math> Ce qui est équivalent à faire le test suivant : : <math>A + B - K = 0</math> En complément à deux, on a <math>- K = \overline{K} + 1</math>. En injectant dans l'équation précédente, on a : : <math>A + B + \overline{K} + 1 = 0</math> En réorganisant les termes, on a : : <math>A + B + \overline{K} = - 1</math> Il suffit d'utiliser un additionneur ''carry-save'' pour faire l'addition des trois termes. Rappelons qu'un tel additionneur fournit deux résultats en sortie : une somme calculée sans propager les retenues et les retenues en question. Notons que les retenues sont à décaler d'un cran, vu qu'elles sont censées s'appliquer à la colonne suivante. En notant la somme S et les retenues R, on a: : <math>S + (R << 1) = - 1 </math>, le décalage d'un cran à gauche étant noté <math><< 1</math>. Ensuite, -1 est codé avec un nombre dont tous les bits sont à 1 en complément à un/deux. : <math>S + (R << 1) = 111 \cdots 111111</math> [[File:Sum + retenue add.png|centre|vignette|upright=2|Sum + retenue add]] Un simple raisonnement nous permet de savoir si le résultat est bien -1, sans faire l'addition <math>S + (R << 1)</math>. En effet, on ne peut obtenir -1 que si la somme est l'inverse des retenues : un 0 dans le premier nombre correspond à un 1 dans l'autre, et réciproquement. En clair, on doit avoir <math>\overline{S} = R << 1</math>. Pour vérifier cela, il suffit de faire un simple XOR entre la somme et les retenues décalées d'un cran. On a alors : : <math>S \oplus (R << 1) = 111 \cdots 111111</math> La comparaison avec -1 se fait avec une porte ET à plusieurs entrées. En effet, la porte donnera un 1 seulement si tous les bits d'entrée sont à 1, ce qui est ce qu'on veut tester. Au final, l'additionneur pour l'addition adresse + décalage est remplacé par un additionneur carry-save suivi d'une couche de portes XOR et d'un comparateur avec une constante, ce qui économise de circuits et améliore les performances. [[File:Final circuit of sum addressed cache.png|centre|vignette|upright=2|Cache adressé par somme.]] En prenant en compte que la constante K est justement une constante, certaines entrées de l'additionneur carry-save sont toujours à 0 ou à 1, ce qui permet quelques simplifications à grand coup d’algèbre de Boole. Chaque additionneur complet qui compose l’additionneur carry-save est remplacée par des demi-additionneurs (ou par un circuit similaire). Autant dire que l'on gagne tout de même un petit peu en rapidité, en supprimant une couche de portes logiques. Le circuit de décodage économise aussi des portes logiques, ce qui est appréciable. ==Les caches à accès uniforme et non-uniforme== Intuitivement, le temps d'accès au cache est le même pour toutes les lignes de cache. Il s'agit de cache appelés '''caches à accès uniforme''', sous-entendu à temps d'accès uniforme. Mais sur les caches de grande capacité, il arrive souvent que le temps de propagation des signaux varie fortement suivant la ligne de cache à lire. D'ordinaire, on se cale sur la ligne de cache la plus lente pour caler la fréquence d'horloge du cache, même si on pourrait faire mieux. Cependant, les '''caches à accès non uniforme''' ont une latence différente pour chaque ligne d'un même cache. Certaines lignes de cache sont plus rapides que d'autres. Niveau terminologie, nous allons parler de caches UCA et NUCA : ''Uniform Access Cache'' pour les caches à accès uniforme, ''Non-Uniform Access Cache'' pour les caches à accès non-uniforme. [[File:Caches UCA et NUCA.png|vignette|Caches UCA et NUCA.]] Les caches NUCA et UCA sont souvent composés de plusieurs banques séparées, typiquement une par voie. Sur les caches UCA, les banques sont interconnectées avec le processeur de manière à ce que toutes les interconnexions ont la même longueur pour toutes les banques. Typiquement, les banques sont organisées en carré, avec les interconnexions qui partent du centre, avec une disposition en H, illustrée ci-contre Mais avec les caches NUCA, ce n'est pas le cas. Les interconnexions sont simplifiées et ont des longueurs différentes. Les caches NUCA n'ont pas tous le même genre d'interconnexions, qui dépendent du cache NUCA. En général, les interconnexion forme un réseau avec des sortes de routeurs qui redirigent les données/commandes vers la bonne destination : cache ou processeur. Les banques plus proches du processeur sont accessibles plus rapidement que celles éloignées, même si la différence n'est pas énorme. Les caches NUCA sont généralement associatifs par voie. Les plus simples utilisent une banque par voie pour le cache, ce qui fait que certaines voies répondent plus vite que les autres. La détection des succès de cache est alors plus rapide si la donnée lue/écrite est dans une voie/banque rapide. En théorie, les défauts de cache demandent de vérifier toutes les banques, et se calent donc sur la pire latence. Mais divers caches se débrouillent pour que ce ne soit pas le cas, soit en vérifiant les banquyes unes par une, soit par un mécanisme de recherche plus complexe. Les caches NUCA sont surtout utilisés pour les caches L3 et L4, éventuellement les caches L2. Les caches L1 sont systématiquement des caches UCA, car la latence de l'accès au cache L1 est utilisée par le processeur pour décider quand lancer les instructions. Pour simplifier, le processeur peut démarrer en avance une instruction avant qu'une opérande soit lue dans le cache L1, de manière à ce que la donnée arrive en entrée de l'ALU pile en même temps que l'instruction. Une histoire d'exécution dans le désordre et d'émission anticipée des instructions qu'on détaillera dans une bonne dizaine de chapitres. Toujours est-il que tout est plus simple pour le processeur si le cache L1 a un temps d'accès fixe. Par contre, les caches L3 et L4 sont traités en attendant que les données arrivent, le processeur reprend l'exécution des instructions quand les caches L3 et L4 ont terminé de répondre, pas avant. Avec l'association une banque = une voie, la correspondance ligne de cache → bloc de mémoire qui est statique : on ne peut pas déplacer le contenu d'une ligne de cache dans une autre portion de mémoire plus rapide suivant les besoins. Mais la recherche académique a étudié le cas où la correspondance entre une ligne de cache et une banque varie à l’exécution. Pour nommer cette distinction, on parle de caches S-NUCA (''Static NUCA'') et D-NUCA (''Dynamic NUCA''). Intuitivement, on s'attend à ce que les caches D-NUCA soient plus performants que les caches S-NUCA. Les lignes de cache les plus utilisées peuvent migrer dans une banque rapide, alors que les lignes de cache moins utilisées vont dans une banque éloignée. Les lignes de cache se répartissent dans le cache dynamiquement dans les banques où elles sont le plus adaptées. Mais paradoxalement, le gain des caches D-NUCA est presque nul, voire insignifiant. La raison est que les caches D-NUCA doivent incorporer un système pour déterminer dans quelle banque se situe la donnée pour détecter les succès/défauts de cache, ainsi qu'un système pour migrer les données entre banques. Et ce système augmente le temps d'accès au cache, réduisant à néant l'intérêt d'un cache D-NUCA. Si on économise quelques microsecondes de temps d'accès en passant d'un cache UCA à un cache S-NUCA, ce n'est pas pour les perdre en passant à un D-NUCA. La majorité des caches D-NUCA sont donc en cours de recherche, mais ne sont pas utilisés en pratique. ==La tolérance aux erreurs des caches== Une mémoire cache reste avant tout une mémoire RAM, bien que ce soit de la SRAM. Elle n'est pas parfaite et est donc sujette à des erreurs, qui peuvent inverser un bit ou l'effacer. De telles erreurs sont liées à des rayons cosmiques très énergétiques, à des particules alpha produites par le packaging ou le métal deu circuit intégré, peu importe : l'essentiel est qu'ils inversent parfois un bit. Les mémoires modernes savent se protéger contre de telles erreurs, en utilisant trois moyens. ===Les mémoires caches ECC et à bit de parité=== Le premier moyen est l'usage de codes correcteurs d'erreurs, qui ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Les bits ajoutés dépendent de la donnée mémorisée dans le byte, et servent à détecter une erreur, éventuellement à la corriger. Le cas le plus simple ajoute un simple bit de parité pour chaque byte et se contente de détecter les erreurs dans les corriger. Les autres codes ECC permettent eux de corriger des erreurs, mais ils demandent d'ajouter au moins deux bits par byte, ce qui a un cout en circuit plus élevé. Un simple bit de parité permet de détecter qu'un bit a été inversé, mais ne permet pas de corriger l'erreur. En soi, ce n'est pas un problème. Si une erreur est détectée, on considère que la ligne de cache est invalide. Le cache gère la situation comme un défaut de cache et va chercher la donnée valide en mémoire RAM. Le cout en circuits est donc faible, mais les défauts de cache sont plus nombreux. Les codes ECC sont eux capables de corriger les erreurs, si elles ne modifient pas trop de bits d'un coup. Par contre, ils utilisent deux à trois bits par octet, ce qui a un cout en circuits loin d'être négligeable. Il y a donc un compromis entre défauts de cache et cout en circuits. La gestion de l'ECC est différente suivant le niveau de cache. Généralement, le cache L1 n'utilise pas l'ECC mais se contente d'un simple bit de parité pour éviter la corruption de ses données. Le cache étant petit, les corruptions de données sont assez rares, et les défauts de cache induits faibles. Il est plus important d'utiliser un code de détection d'erreur simple, rapide, qui ne ralentit pas le cache et n'augmente pas sa latence. Si une ligne de cache est corrompue, il a juste à aller lire la ligne depuis le cache L2, ou un niveau de cache inférieur. Du moins, c'est possible sur le cache en question est un cache inclusif et/ou ''write-through''. Par contre, le niveau de cache L2 et ceux en-dessous utilisent presque systématiquement une mémoire SRAM ECC. La raison principale étant que ce sont des caches assez gros, pour lesquels la probabilité d'une erreur est assez élevée. Plus une mémoire a de bits et prend de la place, plus il y a une chance élevée qu'un bit s'inverse. Et vu que les caches L2/L3/L4 sont par nature plus lents et plus gros, ils peuvent se permettre le cout en performance lié à l'ECC, idem pour le cout en circuit. Sans compter qu'en cas d'erreur, ils doivent aller lire la ligne de cache originelle en mémoire RAM, ce qui est très lent ! Mieux vaut corriger l'erreur sur place en utilisant l'ECC. ===L'usage du ''memory scrubbing'' sur les caches=== La plupart des erreurs ne changent qu'un seul bit dans un byte, mais le problème est que ces erreurs s'accumulent. Entre deux accès à une ligne de cache, il se peut que plusieurs erreurs se soient accumulées, ce qui dépasse les capacités de correction de l'ECC. Dans ce cas, il existe une solution appelée le ''memory scrubbing'', qui permet de résoudre le problème au prix d'un certain cout en performance. Pour rappel, l'idée est de vérifier les lignes de caches régulièrement, pour éviter que les erreurs s'accumulent. Par exemple, on peut vérifier chaque ligne de cache toutes les N millisecondes, et corriger une éventuelle erreur lors de cette vérification. En faisant des vérifications régulières, on garantir que les erreurs n'ont pas le temps de s'accumuler, sauf en cas de malchance avec des erreurs très proches dans le temps. Il ne s'agit pas d'un rafraichissement mémoire, car les SRAM ne s'effacent pas), mais ça a un effet similaire. Et évidemment, le ''memory scrubbing'' a un cout en performance. On peut faire une comparaison avec le rafraichissement mémoire : les rafraichissement réguliers réduisent les performances, car cela fait des accès en plus. Des accès qui sont de plus timés à des instants bien précis qui ne sont pas forcément les plus adéquats. Il est possible qu'un rafraichissement ait lieu en même temps qu'un accès mémoire et le rafraichissement a la priorité, ce qui réduit les performances. La même chose arrive avec les vérifications du ''memory scrubbing''. Malgré tout, la technique a été utilisée sur les caches de certains processeurs commerciaux, dont des processeurs AMD Athlon et Athlon 64. Elle est surtout utilisable sur les caches L2/L3, pour lesquels le cout du pseudo-rafraichissement est acceptable. ==Un exemple de cache : le cache d'instruction== La grande majorité des processeurs utilise deux caches L1 séparés : un '''cache d'instructions''' dédié aux instructions, et un autre pour les données. Une telle organisation permet de charger une instruction tout en lisant une donnée en même temps. Notons que seul le cache L1 est ainsi séparé entre cache de données et d'instructions. Le cache d’instruction se situe en théorie entre l'unité de chargement et l'unité de décodage. En effet, ce cache prend en entrée une adresse et fournit une instruction. L'adresse est fournie par le ''program counter'', l'instruction est envoyée dans l'unité de décodage. Le cache se situe donc entre les deux. Le cache de données L1 est connecté au chemin de données, et notamment aux unités de communication avec la mémoire, pas au séquenceur. [[File:Caches L1 et positions dans le processeur.png|centre|vignette|upright=2.5|Caches L1 et positions dans le processeur]] Les deux caches sont reliés au processeur par des bus séparés, l'ensemble ressemble à une architecture Harvard, mais où les caches remplacent les mémoires RAM/ROM. Le cache d'instruction prend la place de la mémoire ROM et le cache de données prend la place de la mémoire RAM. Évidemment, il y a des niveaux de caches en dessous des caches de données/instruction, et ceux-ci contiennent à la fois données et instructions, les deux ne sont pas séparées dans des mémoires/caches séparés. Raison pour laquelle l'ensemble est appelé une '''architecture Harvard modifiée'''. Architecture Harvard, car l'accès aux données et instructions se font par des voies séparées pour le processeur, modifiée car la séparation n'est effective que pour le cache L1 et pas les autres niveaux de cache, et encore moins la RAM. Sur les processeurs modernes, il arrive très souvent que le processeur doive charger une instruction et lire/écrire une donnée en même temps. Et à vrai dire, c'est la règle plus que l'exception. L'usage d'une architecture Harvard modifiée permet cela très facilement : on peut accéder au cache d'instruction via un bus, et au cache de donnée avec l'autre ===Pourquoi scinder le cache L1 en cache d'instruction et de données=== L'usage d'un cache d’instruction séparé du cache de données est à contraster avec l'usage d'un cache L1 multiport unique, capable de mémoriser à la fois instructions et données. Les deux solutions sont possibles ont été utilisées. Les premiers processeurs avaient un cache L1 unique et multiport, mais ce n'est plus le cas sur les processeurs modernes, car les contraintes ne sont pas les mêmes. Le compromis à faire est celui entre deux petits caches rapides et un gros cache plus lent. Pour rappel, plus un cache est petit, plus il est rapide et chauffe moins. Donc au lieu d'utiliser, par exemple, un gros cache lent de 64 Kibioctets, on utilise deux caches de 32 kibioctets, plus rapides. La capacité totale est la même, mais le temps d'accès plus faible. En termes de temps d'accès, la meilleure solution est celle des deux caches simple port. Mais pour ce qui est de l'économie de circuits, c'est moins évident. Entre deux mémoires simple port et une mémoire multiport, la différence en termes de transistors est ambigüe et dépend de la capacité des caches. La différence est surtout notable pour les gros caches, moins pour les petits caches. Il faut aussi tenir compte de la capacité effective. Avec deux caches séparés, la répartition de la capacité du cache L1 est fixée une bonne fois pour toutes. Par exemple, avec un cache d'instruction de 32 KB et un cache de données de 32 KB, impossible d'allouer 40 KB aux données et 20 aux instructions. Alors qu'avec un cache L1 unique de 64 KB, on pourrait le faire sans soucis. La répartition se fait naturellement, en fonction de la politique de remplacement du cache et est proche de l'optimal. C'est là un désavantage des caches d'instructions/données séparés : une capacité effective moindre. Tout cela explique pourquoi le cache L1 est le seul à être ainsi scindé en deux, avec une séparation entre instructions et données : les contraintes au niveau du cache L1 et L2 ne sont pas les mêmes. Pour les caches L1, le temps d'accès est plus important que la capacité, ce qui favorise les caches séparés. Par contre, pour les caches L2/L3/L4, le temps d'accès n'est pas déterminant, alors que la capacité effective et l'économie en circuits sont significatives. ===La connexion des caches L1 avec le cache L2=== Pour les connexions avec le cache L2, tout dépend du processeur. Certains utilisent un cache L2 multiport, qui permet aux deux caches L1 de lire ou écrire dans le cache L2 simultanément. [[File:Cache d'instructions.png|centre|vignette|upright=1.5|Cache d'instructions.]] Si le cache L2 ne gère pas les accès simultanés, il n'y a qu'un seul bus relié aux caches L1 et au cache L2. On doit effectuer un arbitrage pour décider quel cache a la priorité, chose qui est réalisé par un circuit d'arbitrage spécialisé. [[File:Circuit d'arbitrage du cache.png|centre|vignette|upright=1.5|Circuit d'arbitrage du cache.]] Généralement, les caches d'instructions peuvent se permettre d'être plus petits que les caches de données, car les programmes sont souvent plus petits que les données manipulées. Songez que des programmes de quelques mébioctets peuvent parfois remplir la RAM avec plusieurs gibioctets de données. Lancez votre navigateur internet et ouvrez une page web un peu chargée, pour vous en convaincre ! ===Les spécificités du cache d'instruction : lecture seule, bloquant, etc=== Les instructions sont rarement modifiées ou accédées en écritures, contrairement aux données. Et cela permet d'utiliser un cache simplifié pour les instructions. Autant un cache généraliste doit permettre les lectures et écritures depuis le processeur (avec les échanges avec la RAM), autant un cache d'instruction peut se contenter des lectures provenant du CPU et des échanges avec la RAM. Le cache d'instructions est donc très souvent en « lecture seule » : le processeur ne peut pas écrire dedans, mais juste le lire ou charger des instructions dedans. Un cache d'instruction est donc plus simple qu'un cache pour les données : on peut retirer les circuits en charge de l'écriture (mais on doit laisser un port d'écriture pour charger les instructions dedans). Le gain en circuits permet d'utiliser un cache d'instruction plus gros ou au contraire de laisser de la place pour le cache de données. Le gain en termes de capacité compense alors un peu les inconvénients des caches séparés. Par contre, cela complique la gestion du code automodifiant, c'est-à-dire des programmes dont certaines instructions vont aller en modifier d'autres, ce qui sert pour faire de l'optimisation ou est utilisé pour compresser ou cacher un programme (les virus informatiques utilisent beaucoup de genre de procédés). Quand le processeur exécute ce genre de code, il ne peut pas écrire dans ce cache L1 d'instructions, mais doit écrire dans le cache L2 ou en RAM, avant de recharger les instructions modifiées dans le cache L1. Cela qui prend du temps et peut parfois donner lieu à des erreurs si le cache L1 n'est pas mis à jour. Les algorithmes de remplacement des lignes de cache optimaux pour les données ne le sont pas pour les instructions, de même que la taille optimale du cache, la taille des lignes de cache optimale, ou même les algorithmes de préchargement. Par exemple, pour le remplacement des lignes de cache, un simple algorithme LRU est presque optimal pour les instructions, autant il peut donner de mauvaises performances quand on manipule beaucoup de tableaux. Cela justifie d'utiliser des caches spécialisés pour chacune. On peut adapter le cache d'instruction à son contenu, ce qui le rend plus rapide ou plus petit à performance égale. Les caches d'instructions sont généralement des caches bloquants. Il ne servirait à rien de rendre un cache d'instruction non-bloquant, le cout en circuits ne se traduirait pas par une augmentation significative des performances. À l'opposé, les caches de données sont non-bloquants sur les architectures modernes, pour des raisons de performance. Ce qui rend la séparation assez intéressante, les deux caches ayant des besoins différents et des implémentations différentes, cela permet d'optimiser le cout en transistors des caches. ===L'impact du cache d'instruction sur les performances=== Sur les architectures conventionnelles, le cache d'instruction a plus d'impact sur les performances que le cache de données. La raison principale est que les instructions ont une meilleure localité spatiale et temporelle que pour les données. Pour la localité spatiale, les instructions consécutives se suivent en mémoire, alors que rien ne garantit que des données utilisées ensemble soient regroupées en mémoire. Pour localité temporelle, elle est très variable pour les données, mais très courante pour les instructions du fait de l'usage fréquent des boucles et des fonctions. : La présence de branchements atténue la localité temporelle des instruction, sauf que la majorité des branchements sautent à un endroit très proche, seuls les appels de fonction brisent la localité spatiale. La conséquence est qu'il arrive que certains CPU aient un cache L1 d'instruction plus gros que celui pour les données. On parle alors de '''cache L1 asymétriques'''. Un exemple est celui des processeurs AMD de microarchitecture Zen, dont le cache d'instruction était deux fois plus gros que le cache de données. Leur cache d'instruction faisait 64 kibioctets, contre seulement 32 pour le cache de données. D'ailleurs, il existe des processeurs assez extrêmes qui se contentent d'un cache d'instruction unique, sans cache de données. C'est le cas sur les processeurs vectoriels ou les GPU que nous verrons dans les chapitres de fin de ce wikilivres. De tels processeurs sont spécialisés dans la manipulation de tableaux de données, traitement qui a une faible localité temporelle. En conséquence, utiliser un cache de données n'est pas vraiment utile, voire peu être contreproductif, alors qu'un cache d’instruction fonctionne parfaitement. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Compléments sur les mémoires de masse | prevText=Compléments sur les mémoires de masse | next=Le préchargement | nextText=Le préchargement }} </noinclude> mvf69upw1gywffgiebz26l2vvhbyrpp 765126 765125 2026-04-26T16:03:05Z Mewtow 31375 /* L'exemple des processeurs 386 et du contrôleur de cache 82385 */ 765126 wikitext text/x-wiki Le cache est une mémoire intercalée entre la mémoire et un processeur, plus rarement à l'intérieur d'un périphérique. Il est souvent fabriquée avec de la mémoire SRAM, parfois avec de l'eDRAM. Sans lui, on se croirait à l'âge de pierre tellement nos PC seraient lents ! En effet, la mémoire est très lente comparée au processeur. Le temps mis pour accéder à la mémoire est du temps durant lequel le processeur n'exécute pas d'instruction (sauf cas particuliers impliquant un pipeline). Pour diminuer ce temps d'attente, il a été décidé d'intercaler une mémoire petite mais rapide, entre le processeur et la mémoire. Ainsi, le processeur accède à un cache très rapide plutôt qu'à une RAM beaucoup plus lente. ==L'accès au cache== Le cache contient une copie de certaines données présentes en RAM. La copie présente dans le cache est accessible bien plus rapidement que celle en RAM, vu que le cache est plus rapide. Mais seule une petite partie de ces données sont copiées dans le cache, les autres données devant être lues ou écrites dans la RAM. Toujours est-il que le cache contient une copie des dernières données accédées par le processeur. Une donnée est copiée dans la mémoire cache quand elle est lue ou écrite par le processeur. Le processeur conserve une copie de la donnée dans le cache après son premier accès. Les lectures/écritures suivantes se feront alors directement dans le cache. Évidemment, au fur et à mesure des accès, certaines données anciennes sont éliminées du cache pour faire de la place aux nouveaux entrants, comme nous le verrons plus tard. [[File:Principe d'une mémoire cache.gif|centre|vignette|upright=2|Principe d'une mémoire cache.]] La mémoire cache est invisible pour le programmeur, qui ne peut pas déceler celles-ci dans l'assembleur. Les accès mémoire se font de la même manière avec ou sans le cache. La raison à cela est que le cache intercepte les accès mémoire et y répond s'il en a la capacité. Par exemple, si le cache intercepte une lecture à une adresse et que le contenu de cette adresse est dans le cache, le cache va outrepasser la mémoire RAM et la donnée sera envoyée par le cache au lieu d'être lue en RAM. par contre, si un accès se fait à une adresse pour laquelle le cache n'a pas la donnée, alors l'accès mémoire sera effectué par la RAM de la même manière que si le cache n'était pas là. [[File:Accès au cache.png|centre|vignette|upright=2|Accès au cache]] ===Les succès et défauts de caches=== Tout accès mémoire est intercepté par le cache, qui vérifie si la donnée demandée est présente ou non dans le cache. Si la donnée voulue est présente dans le cache, on a un '''succès de cache''' (''cache hit'') et on accède à la donnée depuis le cache. Sinon, c'est un '''défaut de cache''' (''cache miss'') et on est obligé d’accéder à la RAM. Les défauts de cache peuvent avoir plusieurs origines. Tout ce qu'il faut savoir est que lorsque le processeur accède à une donnée ou une instruction pour la première fois, il la place dans la mémoire cache car elle a de bonnes chances d'être réutilisée prochainement. La raison à cela est qu'un programme a tendance à réutiliser les instructions et données qui ont été accédées dans le passé : c'est le ''principe de localité temporelle''. Bien évidement, cela dépend du programme, de la façon dont celui-ci est programmé et accède à ses données et du traitement qu'il fait, mais c'est souvent vrai en général. La première cause des défauts de cache est liée à la taille du cache. À force de charger des données/instructions dans le cache, le cache fini par être trop petit pour conserver les anciennes données. Le cache doit bien finir par faire de la place en supprimant les anciennes données, qui ont peu de chances d'être réutilisées. Ces anciennes données éliminées du cache peuvent cependant être accédées plus tard. Tout prochain accès à cette donnée mènera à un cache miss. C'est ce qu'on appelle un ''Capacity Cache Miss'', ou encore '''défaut de capacité'''. Les seules solutions pour éviter cela consistent à augmenter la taille du cache ou à optimiser le programme exécuté (voir plus bas). Une autre raison pour un défaut est donc la suivante. Lorsqu'on exécute à une instruction ou qu'on accède à donnée pour la première fois, celle-ci n'a pas encore été chargée dans le cache. Le défaut de cache est inévitable : ce genre de cache miss s'appelle un ''Cold Miss'', ou encore un '''défaut à froid'''. De tels défauts sont presque impossibles à éliminer, sauf à utiliser des techniques de préchargement qui chargent à l'avance des données potentiellement utiles. Ces méthodes de préchargement se basent sur le principe de localité spatiale, à savoir le fait que les programmes ont tendance à accéder à des données proches en mémoire. Pour donner un exemple, les instructions d'un programme sont placées en mémoire dans l’ordre dans lequel on les exécute : la prochaine instruction à exécuter est souvent placée juste après l'instruction en cours (sauf avec les branchements). Quand on accède à une donnée ou une instruction, le cache peut précharger les données adjacentes pour en profiter. Nous parlerons de ces techniques de préchargement dans un chapitre dédié, vers la fin du cours. ===Le fonctionnement du cache, vu du processeur=== Vu du processeur, le cache prend en entrée toutes les informations nécessaires pour effectuer un accès mémoire : des signaux de commande, une adresse et la donnée à écrire si besoin. Tout cela est passé en entrée du cache, celui-ci répondant aux accès mémoire via divers bits de contrôles, que le processeur peut lire à souhait. Le cache fournit aussi la donnée à lire, pour les lectures, sur une sortie, connectée directement au bus mémoire/processeur. Globalement, le cache a une capacité limitée, mais il prend en entrée des adresses complètes. Par exemple, sur un processeur 64 bits, le cache prend en entrée des adresses de 64 bits (sauf si optimisations), même si le cache en question ne fait que quelques mébioctets. Les caches sont souvent des mémoires multiports, surtout sur les processeurs récents. Les caches simple port sont rares, mêmes s'ils existent et ont existé par le passé. les caches double port sont eux plus fréquents, et ont généralement un port d'écriture séparé du port de lecture. Mais les caches récents ont plusieurs ports de lecture/écriture et sont capables de gérer plusieurs accès mémoire simultanés. Les données présentes dans le cache sont (pré)chargées depuis la mémoire, ce qui fait que toute donnée dans le cache est la copie d'une donnée en mémoire RAM. Le cache doit faire la correspondance entre une donnée du cache et l'adresse mémoire correspondante. Du point de vue du fonctionnement, on peut voir le cache comme une sorte de table de correspondance, qui mémorise des données, chacune étant associée à son adresse mémoire. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Cela vaut du point de vue du processeur, le fonctionnement interne du cache étant quelque peu différent selon le cache. Il existe des caches dont le fonctionnement interne est bien celui d'une table de correspondance matérielle, d'autres qui sont beaucoup plus optimisés. [[File:Fonctionnement d'une mémoire associative à correspondance.png|centre|vignette|upright=2|Fonctionnement simplifié d'une mémoire cache : les adresses sont dans la colonne de gauche, les données sont dans la colonne de droite. On voit qu'on envoie l'adresse au cache, que celui-ci répond en renvoyant la donnée associée.]] ==La performance des mémoires caches== L'analyse de la performance des mémoires caches est plus riche pour celle des autres mémoires. Sa performance dépend de beaucoup de paramètres, mais on peut cependant citer les principaux. Les deux premiers sont tout bonnement sa latence et son débit, comme pour n'importe quelle autre mémoire. La latence est plus importante que son débit, car le processeur est généralement plus rapide que le cache et qu'il n'aime pas attendre. Mais le critère le plus important pour un cache est sa capacité à empêcher des accès mémoire, son efficacité. Plus les accès mémoire sont servis par le cache au lieu de la RAM, meilleures seront les performances. Pour résumer, la performance d'un cache est surtout caractérisée par deux métriques : le taux de défaut, qui correspond à l’efficacité du cache, et la latence du cache. ===Le taux de succès/défaut=== Le '''taux de succès''' (hit ratio) est un premier indicateur des performances du cache, mais un indicateur assez imparfait. C'est le pourcentage d'accès mémoire qui ne déclenchent pas de défaut de cache. Plus il est élevé, plus le processeur accède au cache à la place de la RAM et plus le cache est efficace. Certains chercheurs préfèrent utiliser le '''taux de défauts''', à savoir le pourcentage d'accès mémoire qui entraînent un défaut de cache. Plus il est bas, meilleures sont les performances. Le taux de défaut est relié au taux de succès par l'équation <math>T_\text{succes} = 1 - T_\text{defaut}</math>. Par définition, il est égal à : : <math>\text{Taux de défauts de cache} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d’accès mémoires}}</math> Plutôt que de comparer le nombre de défauts/succès de cache au nombre d'accès mémoire, il est aussi possible de diviser le nombre de défauts par le nombre total d'instructions. On obtient alors le '''taux de défauts/succès par instruction''', une autre métrique utile. Par définition, elle est égale à : : <math>\text{Taux de défauts par instruction} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d'instructions}} = \text{Taux de défauts de cache} \times \frac{\text{Nombre d’accès mémoires}}{\text{Nombre d'instructions}}</math> Si certains défauts de cache sont inévitables quel que soit le cache, comme les défauts à froids, mentionnés plus haut, d'autres défauts peuvent être évités en augmentant la capacité du cache. C'est le cas des défauts de capacité qui sont causés par un accès à une donnée qui a été éliminée du cache faute de place. Plus le cache est gros, moins il a de chances d'être rempli, moins il doit rapatrier de données, plus son taux de succès augmente. Mais nous reviendrons sur le lien entre taille du cache et taux de défaut plus bas. Le taux de succès ne dépend pas que du cache, mais aussi de la conception des programmes exécutés. Une bonne utilisation du cache (ainsi que de la mémoire virtuelle) repose sur le programmeur qui doit prendre en compte les principes de localités dès la conception de ses programmes. Par exemple, un programmeur peut parfaitement tenir compte du cache au niveau de son algorithme : on peut citer l'existence des algorithmes ''cache oblivious'', qui sont conçus pour être optimaux quelle que soit la taille du cache. Le programmeur peut aussi choisir ses structures de données de manière à améliorer la localité. Par exemple, un tableau est une structure de donnée respectant le principe de localité spatiale, tandis qu'une liste chaînée ou un arbre n'en sont pas (bien qu'on puisse les implémenter de façon à limiter la casse). D'autres optimisations sont parfois possibles : par exemple, le sens de parcours d'un tableau multidimensionnel peut faire une grosse différence. Cela permet des gains très intéressants pouvant se mesurer avec des nombres à deux ou trois chiffres. Je vous recommande, si vous êtes programmeur, de vous renseigner le plus possible sur les optimisations de code ou algorithmiques qui concernent le cache : il vous suffira de chercher sur Google. Il y a une citation qui résume bien cela, prononcée par un certain Terje Mathisen. Si vous ne le connaissez pas, cet homme est un vieux programmeur (du temps durant lequel on codait encore en assembleur), grand gourou de l’optimisation, qui a notamment travaillé sur le moteur de Quake 3 Arena. {{BlocCitation|Almost all programming can be viewed as an exercise in caching.|auteur=Terje Mathisen}} ===La latence moyenne d'un cache=== Le temps mis pour lire ou écrire une donnée varie en présence d'un cache. Certaines lectures/écritures vont atterrir directement dans le cache (succès) tandis que d'autres devront aller chercher leur contenu en mémoire RAM (défaut de cache). Dans tous les cas, qu'il y ait défaut ou non, le cache sera consulté et mettra un certain temps à répondre, égal au temps de latence du cache. Tous les accès mémoires auront donc une durée au moins égale au temps de latence du cache, qui sera notée <math>T_c</math>. En cas de succès, le cache aura effectué la lecture ou l'écriture, et aucune action supplémentaire n'est requise. Ce qui n'est pas le cas en cas de défaut : le processeur devra aller lire/écrire la donnée en RAM, ce qui prend un temps supplémentaire égal au temps de latence de la mémoire RAM. Un défaut ajoute donc un temps, une pénalité, à l'accès mémoire. Dans ce qui suivra, le temps d'accès à la RAM sera noté <math>T_m</math>. Fort de ces informations, nous pouvons calculer le temps de latence moyen d'un accès mémoire, qui est la somme du temps d'accès au cache (pour tous les accès mémoire), multiplié par le temps lié aux défauts. On a alors : : <math>T = T_c + \text{Taux de défaut} \times T_m</math> On voit que plus le taux de succès est élevé, plus le temps de latence moyen sera bas, et inversement. Ce qui explique l'influence du taux de succès sur les performances du cache, influence assez importante sur les processeurs actuels. De nos jours, le temps que passe le processeur dans les défauts de cache devient de plus en plus un problème au fil du temps, et gérer correctement le cache est une nécessité, particulièrement sur les processeurs multi-cœurs. Il faut dire que la différence de vitesse entre processeur et mémoire est tellement importante que les défauts de cache sont très lents : alors qu'un succès de cache va prendre entre 1 et 5 cycles d'horloge, un cache miss fera plus dans les 400-1000 cycles d'horloge. Tout ce temps sera du temps de perdu que le processeur aura du mal à mitiger. Autant dire que réduire les défauts de cache est beaucoup plus efficace que d'optimiser les calculs effectués par le processeur (erreur courante chez de nombreux programmeurs, notamment débutants). ===L'impact de la taille du cache sur le taux de défaut et la latence=== Il y a un lien entre taille du cache, taux de défaut, débit binaire et latence moyenne. Globalement, plus un cache est gros, plus il est lent. Simple application de la notion de hiérarchie mémoire vue il y a quelques chapitres. Les raisons à cela sont nombreuses, mais nous ne pouvons pas les aborder ici, car il faudrait que nous sachions comment fonctionne un cache et ce qu'il y a à l'intérieur, ce qui sera vu dans la suite du chapitre. Toujours est-il que la latence moyenne d'un cache assez gros est assez importante. De même, le débit binaire d'un cache diminue avec sa taille, mais dans une moindre mesure. Les petits caches ont donc un gros débit binaire et une faible latence, alors que c'est l'inverse pour les gros caches. Une grande capacité de cache améliore le taux de succès, mais cela se fait au détriment de son temps de latence et de son débit, ce qui fait qu'il y a un compromis assez difficile à trouver entre taille du cache, latence et débit. Il peut arriver qu'augmenter la taille du cache augmente son temps d'accès au point d’entraîner une baisse de performance. Par exemple, les processeurs Nehalem d'Intel ont vus leurs performances dans certains jeux vidéos baisser de 2 à 3 %, malgré de nombreuses améliorations architecturales, parce que la latence du cache L1 avait augmentée de 2 cycles d'horloge. Pour avoir une petite idée du compromis à faire, regardons la relation entre taille du cache et taux de défaut. Il existe une relation approximative entre ces deux variables, appelée la '''loi de puissance des défauts de cache'''. Elle donne le nombre total de défaut de cache en fonction de la taille du cache et de deux autres paramètres. Voici cette loi : : <math>\text{Taux de défauts de cache} \approx K \times \text{Taille du cache}^{- \alpha }</math>, avec <math>K</math> et <math>\alpha</math> deux coefficients qui dépendent du programme exécuté. Le coefficient <math>\alpha</math> est généralement compris entre 0.3 et 0.7, guère plus, et varie suivant le programme exécuté. Précisons que cette loi ne marche que si le cache est assez petit par rapport aux données à utiliser. Pour un cache assez gros et des données très petites, la relation précédente est mise en défaut. Pour s'en rendre compte, il suffit d'étudier le cas extrême où toutes les données nécessaires tiennent dans le cache. Dans ce cas, il n'y a qu'un nombre fixe de défauts de cache : autant qu'il faut charger de données dans le cache. Le nombre de défauts de cache observé dans cette situation n'est autre que le coefficient <math>K</math> de la situation précédente, mais il n'y a aucune dépendance entre taux de défaut et taille du cache. L'origine de cette relation s'explique quand on regarde combien de fois chaque donnée est réutilisée lors de l’exécution d'un programme. La plupart des données finissent par être ré-accédées à un moment ou un autre et il se passe un certain temps entre deux accès à une même donnée. Sur la plupart des programmes, les observations montrent que beaucoup de réutilisations de données se font après un temps très court et qu'inversement, peu de ré-accès se font après un temps inter-accès long. Si on compte le nombre de réutilisation qui ont un temps inter-accès bien précis, on retrouve une loi de puissance identique à celle vue précédemment : : <math>\text{Nombre de réaccès avec un temps inter-accès égal à t} \approx K \times t^{- \beta}</math>, avec t le temps moyen entre deux réutilisations. Le coefficient <math>\beta</math> est ici compris entre 1.7 et 1.3. De manière générale, les coefficients <math>\alpha</math> et <math>\beta</math> sont reliés par la relation <math>\alpha = 1 - \beta</math>, ce qui montre qu'il y a un lien entre les deux relations. Précisons cependant que la loi de puissance précédente ne vaut pas pour tous les programmes informatiques, mais seulement pour la plupart d’entre eux. Il n'est pas rare de trouver quelques programmes pour lesquels les accès aux données sont relativement prédictibles et où une bonne optimisation du code fait que la loi de puissance précédente n'est pas valide. La loi de puissance des défauts de cache peut se démontrer à partir de la relation précédente, sous certaines hypothèses. Si un suppose que le cache est assez petit par rapport aux données, alors les deux relations sont équivalentes. L'idée qui se cache derrière la démonstration est que si le temps entre deux accès à une donnée est trop long, alors la donnée accédée aura plus de chance d'être rapatriée en RAM, ce qui cause un défaut de cache. La chance de rapatriement dépend de la taille du cache, un cache plus gros peut conserver plus de données et a donc un temps avant rapatriement plus long. ==Les lignes de cache et leurs tags== Du point de vue du processeur, les lectures et écritures se font mot mémoire par mot mémoire. Un processeur avec des entiers de 64 bits recoit des données de 64 bits de la part du cache, et y écrit des mots de 64 bits. Mais quand on regarde comment sont stockées les données à l'intérieur du cache, les choses sont différentes. ===Les lignes de cache=== Les données sont mémorisées dans le cache par blocs de plusieurs bytes, d'environ 64 à 256 octets chacun, qui portent le nom de '''lignes de cache'''. Les lignes de cache sont l'unité de stockage que l'on trouve à l'intérieur du cache, mais elles servent aussi d'unité de transaction avec la mémoire RAM. Sur les caches actuels, on transfère les données entre le cache et la RAM ligne de cache par ligne de cache, dans la limite de la taille du bus mémoire. Mais d'autres caches plus anciens permettaient de faire des transferts plus fins. C’est-à-dire qu'on pouvait mettre à jour quelques octets dans une ligne de cache sans avoir à la recopier intégralement depuis ou dans la mémoire RAM. En théorie, on pourrait imaginer des caches où les données sont stockées différemment, où l'unité serait le mot mémoire, par exemple. Par exemple, sur un processeur 64 bits, on aurait une ligne de cache de 64 bits. Cela aurait l'avantage de la simplicité : les transferts entre le processeur et la mémoire serait de même taille, l'intérieur du cache ressemblerait à son interface montrée au processeur. Mais cela aurait quelques défauts qui sont compensés par l'organisation en lignes de cache de grande taille. Le premier avantage des lignes de cache est lié à la localité spatiale, la tendance qu'on les programmes à accéder à des données proches les unes des autres. Des accès mémoires consécutifs ont tendance à se faire à des adresses proches, qui ont de bonnes chances d'être dans la même ligne de cache. Et des accès consécutifs à une même ligne de cache sont plus rapides que des accès à deux lignes distinctes. Une autre raison est tout simplement que cela simplifie considérablement la circuiterie du cache. Pour une capacité identique, il vaut mieux avoir peu de lignes de cache assez grosses, que beaucoup de petites lignes de cache. La raison est que les circuits du cache, comme le décodeur, l'encodeur et autres, ont moins de sorties et sont donc plus simples. ===L'alignement des lignes de cache=== Les lignes de cache sont des blocs de plusieurs dizaines à centaines de bytes, dont la taille est presque toujours une puissance de deux. De plus, les lignes de cache sont alignées en mémoire. Nous avions déjà abordé la notion d'alignement mémoire dans un chapitre précédent, mais le concept d'alignement des lignes de cache est quelque peu différent. Quand nous avions parlé d'alignement auparavant, il s'agissait de l'alignement des données manipulées par le processeur, qui faisait partie du jeu d'instruction du processeur. Ici, nous parlons d'un alignement totalement différent, invisible pour le programmeur, sans lien avec le jeu d’instruction. Voyons de quoi il retourne. Concrètement, cela veut dire que du point de vue du cache, la RAM est découpée en blocs qui font la même taille qu'une ligne de cache, aux positions prédéterminées, sans recouvrement entre les blocs. Par exemple, pour un cache dont les lignes de cache font 256 octets, le premier bloc est à l'adresse 0, le second est 256 octets plus loin, c'est à dire à l'adresse 256, le troisième à l'adresse 512, la quatrième à l'adresse 768, etc. Une ligne de cache de 256 octets contiendra une donnée provenant d'un bloc de RAM de 256 octets, dont l'adresse est systématiquement un multiple de 256. Il n'est pas possible qu'une ligne de cache contienne un bloc de 256 octets dont l'adresse du premier octet serait l'adresse 64, ou l'adresse 32, par exemple. En clair, les adresses de ces blocs sont des multiples de la taille de la ligne de cache, de la taille des blocs. Cela rappelle les contraintes d'alignement vues dans le chapitre "Le modèle mémoire : alignement et boutisme", mais appliquées aux lignes de cache. L'alignement des lignes de cache a des conséquences pratiques pour la conception des caches. Notons qu'il est en théorie possible d'avoir des caches dont les lignes de cache ne sont pas alignées, mais cela poserait des problèmes majeurs. Il serait en effet possible qu'une donnée soit présente dans deux lignes de cache à la fois. Par exemple, prenons le cas où une ligne de cache de 256 commence à l'adresse 64 et une autre ligne de cache commence à l'adresse 0. L'adresse 128 serait dans les deux lignes de cache ! Et cela poserait des problèmes lors des lectures, mais encore plus lors des écritures. C'est pour éviter ce genre de problèmes que les lignes de cache sont alignées avec la mémoire RAM dans tous les caches existants. L'alignement des lignes de cache est une chose que les programmeurs doivent parfois prendre en compte quand ils écrivent du code ultra-optimisé, destiné à des programmes demandant des performances extrêmes. Il arrive que les contraintes d'alignement posent des problèmes. Nous avions vu dans le chapitre sur le boutisme et l'alignement qu'il valait mieux gérer l'alignement des variables des structures de données, pour éviter les accès non-alignés avec le bus mémoire. La même chose est possible, mais pour l'alignement avec des lignes de cache. Typiquement, l'idéal est que, pour une structure de donnée, on puisse en mettre un nombre entier dans une ligne de cache. Ou alors, si la structure est vraiment grande, que celle-ci occupe un nombre entier de lignes de cache. Si ce n'est pas le cas, il y a un risque d'accès non-alignés, c'est à dire qu'une structure se retrouve à cheval sur deux lignes de cache, avec les défauts que cela implique. ===Le tag d'une ligne de cache=== Plus haut, nous avions dit que le cache mémorise, pour chaque ligne de cache, l'adresse RAM associée. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Mais du fait de l'organisation du cache en lignes de cache de grande taille, qui sont de plus alignées en mémoire, il faut nuancer cette affirmation. Le cache ne mémorise pas la totalité de l'adresse, ce qui serait inutile. L'alignement des lignes de cache en RAM fait que les bits de poids faible de l'adresse ne sont pas à prendre en compte pour l'association adresse-ligne de cache. Dans ces conditions, on mémorise seulement la partie utile de l'adresse mémoire correspondante, qui forme ce qu'on appelle le '''tag'''. Le reste de l'adresse indique quelle est la position de la donnée dans la ligne de cache. Par exemple, prenons le cas où le processeur gère des nombres entiers de 64 bits (8 octets) et des lignes de cache de 128 octets : chaque ligne de cache contient donc 16 entiers. Si le processeur veut lire ou écrire un entier bien précis, il doit préciser sa place dans la ligne de cache. Et ce sont les bits de l'adresse mémoire non-inclus dans le cache qui permettent de faire ça. En clair, une adresse mémoire à lire/écrire est interprété par le cache comme la concaténation d'un tag et de la position de la donnée dans la ligne de cache correspondante. [[File:Adressage d'un cache totalement associatif.png|centre|vignette|upright=2|Adressage d'un cache totalement associatif]] Le cache est donc une grande table de correspondance entre tags et lignes de cache. Lors d'un accès mémoire, le cache extrait le tag de l'adresse à lire ou écrire, et le compare avec les tags de chaque ligne de cache. Si une ligne contient ce tag, alors c'est que cette ligne correspond à l'adresse, et c'est un défaut de cache sinon. Lors d'un succès de cache, la ligne de cache est lue depuis le cache et envoyée à un multiplexeur qui sélectionne la donnée à lire dans la ligne de cache. Le fonctionnement est similaire pour une écriture : la donnée à écrire passe dans un démultiplexeur, qui envoie la donnée au bon endroit dans la ligne de cache sélectionnée. [[File:Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.png|centre|vignette|upright=2|Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.]] ===Le contenu d'une ligne de cache=== Dans ce qui va suivre, nous allons considérer que chaque ligne de cache mémorise son tag, les données de la ligne de cache proprement dit, et quelques bits de contrôle annexes qui varient suivant le cache considéré. [[File:Tag d'une ligne de cache.png|centre|vignette|upright=2|Tag d'une ligne de cache.]] Les caches modernes incluent de nombreux bits de contrôle, mais deux d'entre eux sont communs à presque tous les caches modernes : le bit ''Dirty'' et le bit ''Valid''. Le '''bit ''Valid''''' indique si la ligne de cache contient des données valides ou non. Si le bit ''Valid'' est à 0, la ligne de cache est en état valide, à savoir qu'elle contient des données et n'est pas vide. Par contre, si ce bit est à 1, la ligne de cache est invalide et son contenu ne peut pas être lu ou écrit. L'utilité de ce bit est qu'il permet d'effacer une ligne de cache très rapidement : il suffit de mettre ce bit à 0. Il existe des situations où le cache doit être effacé, on dit alors qu'il est invalidé. Une section de ce chapitre sera dédié à l'invalidation du cache. Le '''bit ''Dirty''''' indique qu'une ligne de cache a été modifiée. Par modifiée, on veut dire que le processeur a écrit dedans, qu'il a modifié la ligne de cache. Mais attention : si la donnée a été modifiée dans le cache, la modification n'est pas forcément propagée en mémoire RAM. Le bit ''dirty'' indique si c'est le cas, si l'écriture a été propagée en mémoire RAM. Il précise que la ligne de cache contient des données modifiées, alors que la RAM a des données initiales non-modifiées. Une ligne de cache avec un bit ''dirty'' à 1 est dite ''dirty'', par métonymie. Nous verrons cela en détail dans la section sur les caches ''write-back'' et ''write-through''. Les caches modernes ajoutent des '''bits de détection/correction d'erreur''' dans les bits de contrôle. Pour rappel, les codes de détection/correction d'erreur permettent de se prémunir contre des erreurs matérielles, qui corrompent les données stockées dans une mémoire, ici une mémoire cache. Ils ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Nous reviendrons dessus dans une section ultérieur de ce chapitre. Sur certains caches assez anciens, on pouvait transférer les lignes de caches morceaux par morceaux. Ces caches avaient des lignes de cache divisées en sous-secteurs, ces sous-secteurs étant des morceaux de ligne de cache qu'on pouvait charger indépendamment les uns des autres (mais qui sont consécutifs en RAM). Chaque secteur avait ses propres bits de contrôle, mais le tag était commun à tous les secteurs. [[File:Cache à secteurs.png|centre|vignette|upright=2.5|Cache à secteurs.]] : Dans ce qui va suivre, le terme "ligne de cache" désignera soit un bloc de données copiées depuis la RAM d'une taille de 64/128/256/... octets, soit la concaténation de ces données avec le tag et des bits de contrôle. Les deux définitions ne sont pas équivalentes, mais l'usage a entériné cet abus de langage. Et il faut avouer que cela rend les explications du chapitre plus simples. ==Les instructions de contrôle du cache== Plus haut, nous avions dit que le cache est totalement transparent du point de vue du programmeur. Le cache contient des copies de données en RAM, le programmeur n'a rien à faire pour utiliser le cache correctement. Mais la réalité est que pour des raisons diverses, des processeurs incorporent des '''instructions de contrôle du cache'''. Il s'agit d’instructions qui agissent sur le contenu du cache. Elles existent pour des raisons diverses qu'on détaillera plus bas, mais il s'agit globalement d'une question de performances ou de nécessité pour le système d'exploitation. ===Les instructions de préchargement=== La première instruction de contrôle du cache est une '''instruction de préchargement''', qui demande à charger un bloc de données dans le cache. Elle prend en opérande une adresse mémoire, et le contenu de cette adresse est chargé dans une ligne de cache. Bien sûr, des contraintes d'alignement sont à prendre en compte : on charge un bloc de la même taille qu'une ligne de cache, aligné en mémoire sur la taille du bloc, qui contient l'adresse. L'instruction de préchargement n'est utile que si l'instruction est exécutée bien avant que la donnée ne soit utilisée/lue/écrite. Cela permet de charger une donnée dans le cache à l'avance, d'où le nom de préchargement donné à cette technique. Mais les processeurs modernes gérent des techniques de préchargement automatique, qui ne requièrent pas d'instructions de préchargement. Le préchargement automatique et les instructions de préchargement sont deux solutions complémentaires, mais qui peuvent se marcher sur les pieds. Nous en reparlerons dans le prochain chapitre, qui sera dédié au préchargement automatique. Il faut noter que les instructions de préchargement peuvent être ignorées par le processeur. Sous certaines conditions, le processeur peut décider que l'instruction de préchargement ne sera pas exécutée. Par exemple, il ne va pas précharger une donnée déjà présente dans le cache. Ou encore, si le bus mémoire est occupé, il ne va pas exécuter le préchargement, par manque de ressources matérielles. ===Les instructions d'invalidation et de ''flush''=== Les instructions ''flush'' regroupent deux types d'instructions qui sont souvent utilisées en même temps. Il s'agit des instructions d'invalidation et de nettoyage (''clean''). Les deux termes proviennent de la terminologie ARM, il n'y a pas de terminologie standardisé pour les noms de ces instructions. Dans les grandes lignes, elles permettent de vider le cache, à savoir de rapatrier son contenu en RAM et de réinitialiser le cache à zéro. Elles sont utilisées par le système d'exploitation lors des commutations de contexte, à savoir quand on passe d'un programme à un autre. Elles sont aussi utilisées lors des appels systèmes et routines d'interruption/exception. L'idée est de vider le cache avant d'exécuter un nouveau programme ou une nouvelle routine. Le nouveau programme aura accès à un cache tout propre, les données de l'ancien programme auront été retirée du cache. Les '''instructions ''clean''''' recopient le contenu de la ligne de cache en RAM. Elles forcent la recopie immédiatement de la ligne de cache en mémoire RAM. Pour faire leur travail, elle vérifient si la ligne de cache a été modifiée, avant de la recopier en RAM. Et pour cela, ils vérifient le bit de contrôle ''dirty'', qui est mis à 1 après une première écriture. Si ce bit est à 0, alors pas besoin de recopier la ligne de cache : elle n'a pas été modifiée, la RAM a déjà la bonne copie. Mais s'il est à 1, le cache et la RAM n'ont pas le même contenu, la recopie s'exécute. Les '''instructions d'invalidation''' permettent d'invalider une ligne de cache, à savoir d'effacer son contenu. Nous verrons à quoi servent ces instructions dans la section sur les changement de processus. Invalider une ligne de cache est une opération optimisée : le cache n'est en réalité pas réellement effacé. À la place, le bit ''Valid'' de chaque ligne de cache est juste mis à 0. Il faut noter que l'invalidation efface les lignes de cache sans se préoccuper de leur contenu. Elle se moque qu'une ligne de cache contienne une donnée modifiée, ''dirty'' ou quoique ce soit : la ligne de cache est effacée, point. Il est possible d'invalider une ligne de cache en fournissant une adresse mémoire, mais il est aussi possible d'invalider le cache tout entier. Le choix entre les deux dépend du mode d'adressage de l'instruction d'invalidation. Parfois, il existe une instruction séparée pour invalider tout le cache, et une autre pour invalider une ligne de cache bien précise. Des instructions séparées sont parfois disponibles pour invalider les caches de données et d'instructions, parfois aussi la TLB (un cache qu'on verra dans quelques chapitres). Il est possible de n'invalider que le cache L1, voire le cache L2. Il faut noter que l'invalidation efface tout le cache, mais ne se préoccupe pas de vérifier si les données ont été modifiées dans le cache. Pour certains caches, comme le cache d'instruction, ce n'est pas un problème, vu qu'il est en "lecture seule". Mais pour les caches de données, les données modifiées sont perdues en cas d'invalidation. Heureusement, il existe des instructions d'invalidation qui fusionnent une instruction ''clean'' et une instruction d'invalidation. Il s'agit d''''instructions d'invalidation spéciales'''. ===Les instructions d'optimisation : instructions non-temporelles et écritures optimisées=== Les '''instructions mémoire non-temporelles''' contournent complètement le cache. Par exemple, une lecture peut lire une donnée, mais celle-ci ne sera pas chargée dans le cache, elle passe directement de la RAM vers les registres. Une section entière de ce chapitre sera dédiée au contournement du cache, à savoir aux situations où les accès mémoire doivent passer directement du processeur à la RAM sans passer par le cache. D'autres instructions assez rares incorporent des indications pour le cache. Par exemple, l'instruction ''load last'' des processeurs POWER PC implique que la donnée ne sera utilisée qu'une seule fois. Elle est donc chargée dans le cache, mais la ligne de cache est configurée de manière à être remplacée très rapidement, typiquement avec une valeur de LRU/LFU adéquate. La donnée est bien chargée dans le cache, au cas où elle doive être relue suite à une mauvaise prédiction de branchement ou autre, chose qu'une lecture non-temporelle (qui contourne le cache) ne fait pas. Des indications de ce type sont appelées des '''''cache hint'''''. L''''instruction ''flush''''' permet de préciser qu'une ligne de cache contient une donnée inutile, qui ne sera pas réutilisée par le programme. Pas besoin de la conserver dans le cache, elle peut laisser sa place à des données plus utiles. Or, sans indication, les algorithmes de remplacement d'une ligne de cache risquent de conserver cette donnée trop longtemps, ce qui entraine une certaine pollution du cache par des données inutiles. Une autre instruction est elle beaucoup plus importante : celle de '''pré-allocation sur écriture'''. Elle sert dans le cas où une ligne de cache est complétement écrite. Par exemple, imaginons qu'on veuille écrire dans une portion de mémoire. Si celle-ci n'est pas dans le cache, le processeur va charger une ligne de cache complète depuis la RAM, écrire dans la ligne de cache, puis recopier la ligne de cache modifiée en mémoire RAM. Une écriture en RAM demande donc de faire une lecture et une écriture. Mais les instructions de pré-allocation sur écriture permettent de prévenir qu'une ligne de cache sera intégralement écrite, et qu'il n'y a donc pas besoin de lire celle-ci depuis la RAM. Notons que l'instruction d'écriture qui suit n'est pas une écriture non-temporelle, vu que les données sont écrites dans la ligne de cache, qui est ensuite envoyée en mémoire RAM dès que nécessaire. De plus, les données écrites peuvent ensuite être relue depuis le cache si nécessaire. Enfin, certains processeurs MIPS incorporent une instruction pour modifier le tag d'une ligne de cache. Elles servent à optimiser les copies mémoire, à savoir quand on copie un bloc de données d'un endroit à un autre. L'idée est de charger le bloc de données dans le cache avec une instruction LOAD/PREFETCH, de modifier le tag pour qu'il pointe vers l'adresse à écrire, et de laisser faire le cache pour que l'écriture se fasse en RAM. Mais les contraintes pour utiliser cette instruction sont assez drastiques : les données doivent être alignées sur la taille d'une ligne de cache, le bloc de départ et d'arrivée (l'original versus la copie) ne doivent pas se recouvrir, etc. ==L'associativité des caches et leur adressage implicite== Lorsqu'on souhaite accéder au cache, il faut trouver quelle est la ligne de cache dont le tag correspond à l'adresse demandée. On peut classifier les caches selon leur stratégie de recherche de la ligne correspondante en trois types de caches : totalement associatifs, directement adressés (''direct mapped'') et associatifs par voie. ===Les caches totalement associatifs=== Avec les caches totalement associatifs, toute donnée chargée depuis la mémoire peut être placée dans n'importe quelle ligne de cache, sans aucune restriction. Ces caches ont un taux de succès très élevé, quand on les compare aux autres caches. [[File:Cache totalement associatif.png|centre|vignette|upright=2|Cache totalement associatif.]] Concevoir un cache totalement associatif peut se faire de deux grandes manières différentes. La première consiste tout simplement à combiner une mémoire associative avec une mémoire RAM, en ajoutant éventuellement quelques circuits annexes. La mémoire associative mémorise les tags, alors que la mémoire RAM mémorise les données de la ligne de cache, éventuellement avec quelques bits de contrôle. La ligne de cache est stockée à une adresse A dans la mémoire RAM et son tag est stocké à la même adresse, mais dans la mémoire CAM. Ce faisant, quand on envoie le tag à la mémoire CAM, elle renvoie l'adresse de la ligne de cache dans la mémoire RAM. Cette adresse est alors envoyée directement sur le bus d'adresse de la RAM, et la lecture est effectuée automatiquement. Il faut ajouter quelques circuits annexes pour garantir que les écritures se passent correctement dans les deux mémoires, mais rien de bien terrible. [[File:Cache fabriqué avec une mémoire associative et une RAM.png|centre|vignette|upright=3|Cache fabriqué avec une mémoire associative et une RAM]] Il est cependant possible d'optimiser un tel cache, en fusionnant la mémoire CAM et la mémoire RAM, afin d'éliminer des circuits redondants. Pour comprendre pourquoi, rappelons que les mémoires CAM sont composées d'un plan mémoire, d'un paquet de comparateurs et d'un encodeur. Quant à la mémoire RAM, elle est composée d'un décodeur connecté au plan mémoire. En mettant une CAM suivie d'une RAM, on a un encodeur dont l'entrée est envoyée à un décodeur. [[File:Cache totalement associatif naif.png|centre|vignette|upright=3|Cache totalement associatif naif]] Or, le décodeur réalise l'opération inverse de l'encodeur, ce qui fait que mettre les deux composants à la suite ne sert à rien. On peut donc retirer l'encodeur et le décodeur, et envoyer directement les résultats des comparateurs sur les entrées de commande du plan mémoire de la RAM. [[File:Cache totalement associatif optimisé.png|centre|vignette|upright=2|Cache totalement associatif optimisé]] Avec cette méthode, les circuits du cache ressemblent à ce qui illustré ci-dessous. Le tag est envoyé à chaque ligne de cache. Le tag envoyé est alors comparé avec le Tag contenu dans chaque ligne de cache, comme c'est le cas sur les mémoires associatives. Si une ligne de cache matche avec le tag envoyé en entrée, la ligne pour laquelle il y a eu une égalité est alors connectée sur les lignes de bit (''bitlines''). Cela est réalisé par un circuit commandé par le comparateur de la ligne de cache. Il ne reste plus qu'à sélectionner la portion de la ligne de cache qui nous intéresse, grâce à un paquet de multiplexeurs. Cela permet d'effectuer une lecture ou écriture, mais il faut aussi préciser si il y a eu un défaut de cache ou un succès. Un succès de cache a lieu quand au moins des comparaisons est positive, alors que c'est un défaut de cache sinon. En clair, détecter un succès de cache demande juste de connecter une porte OU à plusieurs entrées à tous les comparateurs. [[File:Organisation générale d'un cache totalement associatif.png|centre|vignette|upright=2|Organisation générale d'un cache totalement associatif.]] ===Les caches directement adressés=== Les caches directement adressés peuvent être vus comme un cache totalement associatif auquel on aurait ajouté des restrictions assez drastiques. Plus haut, on a vu qu'un cache totalement adressé est équivalent à la combinaison d'une CAM avec une RAM. La mémoire CAM prend en entrée un Tag et traduit celui-ci en une adresse qui commande la mémoire RAM interne au cache. Dans ce qui suit, l'adresse interne au cache sera appelé l''''indice''' pour éviter toute confusion. [[File:Cache hash table - 2.png|centre|vignette|upright=2|Fonctionnement interne du cache, expliquée sous forme abstraite, en utilisant la notion d'indice interne au cache.]] Les caches directement adressés cherchent à remplacer la mémoire CAM par un circuit combinatoire. Ce circuit traduit le Tag en indice, mais est beaucoup plus simple qu'une mémoire CAM. Mais qui dit circuit plus simple dit circuit plus limité. Un circuit combinatoire n'est pas aussi versatile que ce qui est permis avec une mémoire CAM. En conséquence, une restriction majeure apparait : toute adresse mémoire est associée dans une ligne de cache prédéfinie, toujours la même. L'association entre ligne de cache et adresse mémoire est faite par le circuit combinatoire, et ne peut pas changer. Les concepteurs de caches s'arrangent pour que des adresses consécutives en mémoire RAM occupent des lignes de cache consécutives, par souci de simplicité. Tout se passe comme suit la mémoire RAM était découpés en blocs de la même taille que le cache. La première adresse du bloc est associée à la première ligne de cache (celle d'indice 0), la seconde adresse est associée à la seconde adresse du_ bloc, et ainsi de suite. Le tout est illustré ci-dessous. [[File:Cache adressé directement.png|centre|vignette|upright=2|Cache adressé directement.]] Avec cette contrainte, le circuit de traduction de l'adresse en adresse mémoire pour la RAM interne au cache est drastiquement simplifié, et disparait même. Une partie de l'adresse mémoire sert à indiquer la position de la donnée dans le cache, le reste de l'adresse sert encode le tag et la position de la donnée dans le ligne de cache. [[File:Cache line.png|centre|vignette|upright=2|Adresse d'une ligne de cache sur un cache adressé directement.]] Un cache directement adressé est conçu avec une RAM, un comparateur, et un paquet de multiplexeurs. En général, la mémoire RAM stocke les lignes de caches complète. Il arrive que l'on utilise deux mémoires RAM : une pour les tags et une pour les données, mais cette technique augmente le nombre de circuits et de portes logiques nécessaires, ce qui réduit la capacité du cache. L'index à lire/écrire est envoyé sur l'entrée d'adresse de la RAM, la RAM réagit en mettant la ligne de cache sur sa sortie de donnée. Sur cette sortie, un comparateur compare le tag de la ligne de cache lue avec le tag de l'adresse à lire ou écrire. On saura alors si on doit faire face à un défaut de cache. Ensuite, un multiplexeur récupère la donnée à lire/écrire. [[File:Direct mapped cache - french.png|centre|vignette|upright=2|Cache directement adressé.]] L'accès à un cache directement adressé a l'avantage d'être très rapide vu qu'il suffit de vérifier une seule ligne de cache : celle prédéfinie. Mais ces caches ne sont cependant pas sans défauts. Vu que le cache est plus petit que la mémoire, certaines adresses mémoires se partagent la même ligne de cache. Si le processeur a besoin d’accéder fréquemment à ces adresses, chaque accès à une adresse supprimera l'autre du cache : tout accès à l'ancienne adresse se soldera par un défaut de cache. Ce genre de défauts de cache causés par le fait que deux adresses mémoires ne peuvent utiliser la même ligne de cache s'appelle un '''défaut par conflit''' (''conflict miss''). Les défauts par conflit n'existent pas sur les caches totalement associatifs. En conséquence, le taux de succès des caches directement adressés est assez faible comparé aux autres caches. [[File:Cache Block Basic Conflict.svg|centre|vignette|upright=1.5|Exemple de ''Conflict Miss''.]] ===Les caches associatifs par voie=== Les caches associatifs par voie sont un compromis entre les caches directement adressés et les caches totalement associatifs. Pour simplifier, ces caches sont composés de plusieurs caches directement adressés accessibles en parallèle, chaque cache/RAM étant appelé une '''voie'''. Avec ces caches, toute adresse mémoire en RAM est associée à une ligne de cache dans chaque voie. [[File:Cache associatif par voie.png|centre|vignette|upright=2|Cache associatif par voie.]] Le schéma ci-dessous compare un cache directement adressé et un cache associatif à deux voies. On voit que chaque adresse est associée à une ligne de cache bien précise avec un cache directement dressé, et à deux lignes de cache avec un cache associatif à deux voies. L'adresse sera associée à 4 lignes de cache sur un cache associatif à 4 voies, à 8 lignes pour un cache à 8 voies, etc. L'ensemble des lignes de cache associées à une adresse est appelé un '''ensemble'''. [[File:Cache Fill.svg|centre|vignette|upright=2|Comparaison entre un cache directement adressé et un cache associatif à deux voies.]] Sur ces caches, toute adresse est découpée en trois parties : un tag, un index, et un décalage, comme sur les caches directement adressés. Comme vous pouvez le voir, l'organisation est identique à celle d'un cache totalement associatif, à part que chaque ensemble tag-ligne de cache est remplacé par une mémoire RAM qui en contient plusieurs. [[File:Implémentation d'un cache associatif par voie.png|centre|vignette|upright=2|Implémentation d'un cache associatif par voie.]] Le risque de conflits d'accès au cache est donc réduit sur un cache associatif à plusieurs voies, et il est d'autant plus réduit que le cache a de voies. Par contre, leur conception interne fait qu'ils ont un temps d'accès légèrement élevé que les caches directement adressés. Les caches associatifs par voie ont donc un taux de succès et un temps d'accès intermédiaire, situé entre les caches directement adressés et totalement associatifs. Ils sont une sorte de compromis entre réduction des défaut par conflits d'accès au cache et temps d'accès, et complexité des circuits. ==Les optimisations des caches associatifs par voie== Les caches partiellement associatifs regroupent les caches associatifs par voie et directement adressés, ainsi que leurs variantes. En clair : tous les caches qui ne sont pas totalement associatifs. Ils peuvent être optimisés de nombreuses manières, que ce soit pour gagner en performance ou pour économiser de l’énergie. Dans cette section, nous allons voir quelles sont ces optimisations. ===Les caches pseudo-associatifs=== Les caches adressés par voie contiennent une mémoire SRAM par voie. En théorie, les voies sont accédées en parallèles, en même temps, afin de voir si l'on a un succès de cache ou un défaut. Les '''caches pseudo-associatifs''' sont identiques aux caches associatifs par voie, si ce n'est qu'ils vérifient chaque voie une par une. Ils ont été utilisés sur des processeurs commerciaux, un exemple étant l'IBM 370. Là encore, on perd en performance pour gagner en consommation d'énergie. Le temps d'accès dans le meilleur des cas est plus faible pour les caches pseudo-associatifs, mais le pire des cas teste tous les caches avant de tomber sur le bon. Les performances sont donc réduites. Mais la consommation énergétique est meilleure, vu qu'on ne vérifie pas forcément toutes les voies en parallèle. On teste la première voie, éventuellement la seconde, peut-être la troisième, etc. Mais dans le cas général, on ne teste qu'une partie des voies, pas toutes, ce qui donne un gain en termes d'énergie. L'implémentation de caches de ce genre demande que l'on parcoure les voies une par une, en commençant de la première jusqu'à la dernière. Pour cela, un simple compteur suffit. Suivant la valeur du compteur, la voie associée est activée puis accédée. Toute la complexité revient à ajouter un circuit qui prend la valeur du compteur, et active la voie associée, lance un accès mémoire dessus. Vu que les voies sont chacune des caches ''direct mapped'', il suffit pour cela de geler les entrées d'adresse, soit en les déconnectant, soit en utilisant du ''clock gating'' ou de l'évaluation gardée. Les détails d'implémentation, non-cités ici, varient selon le cache. ===La prédiction de voie=== Pour réduire le temps d'accès des caches pseudo-associatifs, certains chercheurs ont inventé la '''prédiction de voie''', qui consiste à faire des paris sur la prochaine voie accédée. L'idée est d'accéder à la voie qui contient la donnée voulue du premier coup, en lisant celle-ci en priorité. Dans son implémentation la plus simple, le cache reste un cache pseudo-associatif. Lors d'un accès au cache, les voies sont toutes parcoures une par une. Par contre, les voies ne sont donc pas parcourues de la première vers la dernière, mais dans un ordre différent. Cette technique permet de mettre en veille les voies sur lesquels le processeur n'a pas parié, ce qui permet de diminuer la consommation énergétique du processeur. C'est plus efficace que d'aller lire plusieurs données dans des voies différentes et de n'en garder qu'une. L'implémentation est assez simple : il suffit d'ajouter un circuit de prédiction de voie,relié au compteur de voie. Une amélioration de la technique fait fonctionner le cache comme un intermédiaire entre cache pseudo-associatif et associatif par voies. L'idée est de chercher la voie prédite en premier, puis de chercher dans toutes les voies en parallèle en cas de défaut de cache. Au lieu d'attendre que les comparaisons de tags donnent leur résultat, le processeur sélectionne automatiquement une voie et configure les multiplexeurs à l'avance. Si le processeur ne se trompe pas, le processeur accède à la donnée plus tôt que prévu. S'il se trompe, le processeur annule la lecture effectuée en avance et recommence en faisant un accès en parallèle aux autres voies. Le compromis entre performance et consommation d'énergie est alors différent. On économise de l'énergie par rapport à un cache associatif par voie, au prix d'une petite perte de performance (doublement des temps d'accès). Mais par rapport à un cache pseudo-associatif, l'économie d'énergie est bien moindre, au prix d'un gain en performance assez manifeste. Prédire quelle voie sera la bonne est assez simple. En vertu du principe de localité, les accès futurs ont des chances de tomber dans les voies les plus fréquemment utilisées ou dans celle plus récemment utilisée. Il suffit de retenir la voie la plus récemment accédée dans un registre, qui sera utilisée comme prédiction. Pour vérifier que la prédiction est correcte, il suffit de comparer le registre et le résultat obtenu après vérification des tags. Cependant, on peut complexifier l'implémentation pour prendre en compte l'adresse à lire/écrire, l'instruction à l'origine de l'accès mémoire ou tout autre paramètre utile. Par exemple, des instructions différentes ont tendance à aller chercher leurs données dans des ensembles différents et la voie à choisir n'est pas la même. Pour cela, il suffit d'utiliser un cache pour stocker la correspondance instruction - voie. Pour plus de simplicité, la mémoire cache des prédictions est parfois remplacée par une RAM, qui est adressée : * soit par le program counter de l'instruction à l'origine de l'accès (en réalité, seulement quelques bits de poids faible de l'adresse) ; * soit par l'adresse à accéder (là encore, quelques bits de poids faible) ; * soit (pour les modes d'adressage qui utilisent un registre de base et un décalage) par un XOR entre les bits de poids faible de l'adresse de base et le décalage ; * soit par autre chose. ===La mise en veille sélective des voies=== Les caches associatifs ont tendance à utiliser beaucoup d'énergie, même quand on n'y accède pas. Aussi, certains processeurs détectent quand le cache est peu utilisé et en profitent pour mettre en veille les voies inutilisées. Vous vous demandez certainement ce qui se passe quand une donnée à lire/écrire est dans une voie désactivée. La réponse est que le cache détecte cette situation, car elle déclenche un succès de cache. Les ''tags'' ne sont en effet pas désactivés, seules les données sont mises en veille. L'implémentation est plus simple sur les caches qui séparent les tags et les données dans deux RAM différentes. Cette optimisation marche surtout sur les gros caches, qui ont des chances d'avoir une portion significative d’inutilisée (pas assez de données pour les remplir), donc généralement les caches L3/L4. Par exemple, les processeurs d'Intel de microarchitecture Ivy Bridge disposent d'un cache de 8 mébioctets à 16 voies, qu'ils peuvent faire passer à 512 kibioctets si le besoin s'en fait sentir. Quand ces processeurs détectent une faible activité, ils mettent en veille 14 voies et n'en gardent que 2 d'actives. Évidemment, les 14 voies sont vidées avant d'être mises en veille, afin qu'une aucune donnée ne soit perdue. ===Les caches ''skew-associative''=== Vous aurez remarqué que dans une voie, les lignes sont accédées en adressage direct : les défauts par conflit sont possibles sur un cache associatif par voie. Pour éviter cela, certains chercheurs ont créé des '''caches ''skew associative''''' (ou associatifs à biais). Pour faire simple, les index des lignes de cache subissent un petit traitement avant d'être utilisés. Le traitement en question est différent suivant la voie de destination, histoire que deux adresses mémoires avec des index identiques donnent des index différents après traitement. Le traitement en question est souvent une permutation des bits de l'index, qui est différente suivant la voie prise, ou un simple XOR avec un nombre qui dépend de la voie. [[File:Implémentation d'un cache skew associative.jpg|centre|vignette|upright=2|Implémentation d'un cache skew associative.]] ==Les caches splittés (''phased caches'')== Dans cette section, nous allons voir les '''caches splittés''' (''phased caches''), qui sont une variante des caches ''direct-mapped'', dans lequel le cache est accédé en deux étapes consécutives. Il ne s'agit pas des caches pipelinés, que nous verrons dans le chapitre sur les processeurs pipélinés, mais laissons cela à plus tard. Il est possible d'appliquer la même méthode sur un cache associatif par voie, mais il y a des méthodes plus simples, qui permettent là aussi d’accéder au cache en plusieurs étapes consécutives. L'idée est de scinder le cache en deux : une mémoire pour les tags, une autre pour les données de la ligne de cache. Les bits de contrôle peuvent être mis dans l'une ou l'autre SRAM, mais ils sont souvent mis dans la RAM pour les tags. En faisant cela, quelques optimisations deviennent possibles, afin de réduire la consommation énergétique en contrepartie d'une perte de performance. La technique s'implémente différemment pour les caches totalement associatifs et partiellement associatifs. Les caches totalement associatifs splittés sont ceux formés en combinant un cache associatif avec une CAM et une RAM combinée. On envoie l'adresse à lire/écrire à la mémoire associative, elle répond en envoyant une adresse à la mémoire RAM. L'accès se fait donc en deux temps, avec l'adresse dans la RAM comme intermédiaire. Il est possible de séparer physiquement les deux étapes en insérant un registre entre la CAM et la RAM, ce qui permet aussi de pipeliner l'accès. Mais c'est rarement fait en pratique, car le cout en circuit d'une mémoire CAM est trop important. L'équivalent pour un cache totalement associatif optimisé, sans CAM et RAM séparée, est trop gourmande en interconnexions pour être implémentée. Les caches totalement associatifs splittés sont donc très rares, l'auteur ne connait aucun exemple de processeur avec un tel cache. Il existe une technique équivalente pour les caches ''direct-mapped'', mais elle demande une certaine modification du cache. Dans les caches ''direct-mapped'' non-splittés, on trouve une mémoire SRAM dont chaque mot mémoire contient une ligne de cache entière, tag inclus. Dans leurs versions splittés, la SRAM est séparée en deux : une pour les tags, une autre pour les données. Précisons qu'il s'agit bien de deux mémoires SRAM adressables. L'adresse à laquelle accéder est envoyée à la SRAM des tags, puis ensuite à la SRAM des données si besoin. L'idée est d’accéder aux tags pour déterminer s'il y a un succès de cache ou un défaut, et ensuite d'accéder aux données. On n’accède pas aux données en parallèle des tags. Faire cela est évidemment plus lent. En cas de défaut de cache, le temps d'accès est similaire : le tag ne correspond pas, on n'accède pas à la SRAM pour les données. Par contre, vu qu'on n'a pas activé la SRAM pour les données, on économise un peu d'énergie, ce qui réduit la consommation d'énergie. En cas de succès de cache, on accède à la SRAM pour les tags, puis à celle pour les données. Pas d'économie d'énergie à l'horizon, sans compter que le temps d'accès augmente : on accède au cache en deux étapes au lieu de faire les deux accès en parallèle. [[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]] Précisons cependant que ce design peut avoir deux avantages en termes de performance. Premièrement, le temps d'accès au cache est légèrement amélioré en cas de défaut de cache. En effet, la SRAM des tags est assez petite, idem pour celle des données. Leur temps d'accès est donc plus faible que pour une grosse SRAM contenant données et tags. Le gain en temps d'accès est donc un avantage, qui ne se manifeste surtout en cas de défaut de cache. Un autre avantage est que l'accès au cache se pipeline plus facilement, ce qui fait qu'on peut effectuer plusieurs accès simultanés au cache. Mais nous verrons cela dans quelques chapitres. ===Le contrôleur de cache 82385 pour les CPU Intel 386=== Il est important de noter que la séparation entre tags et RAM peut être telle que les deux ne sont pas sur la même puce de silicium ! Voire que les deux sont séparés du processeur ! C'était le cas quand les mémoires caches ont été introduites sur les processeurs grand public, notamment sur les premiers processeurs Intel. La miniaturisation n'avait pas avancé au point où placer un cache dans le processeur était possible. Sur le processeur 386 d'Intel, le cache était un cache splitté, séparé du processeur. Concrètement, le processeur i386 était couplé à un contrôleur de cache Intel 82385 et une mémoire SRAM. Le 82385 contenait les ''tags'' et les bits de contrôle, la SRAM contenait les données, les lignes de cache. Un point important est que les lignes de cache faisaient seulement 32 bits/4 octets, pas plus ! On était loin des lignes de cache actuelles, faisant 64 octets/512 bits. Mais c'était beaucoup plus pratique, vu que le bus système faisait 32 bits de large, idem pour l'interface avec le processeur. Pour intégrer un cache facultatifs (certains ordinateurs 'en passaient). Le schéma ci-dessous montre comment le cache s'intégrait avec le bus système. Pour le bus de commande, le cache servait d'intermédiaire : il recevait les commandes et et les filtrait suivant les succès/défauts de cache. En cas de succès de cache, les commandes de lecture n'étaient pas envoyées à la mémoire RAM. Les adresses étaient transmises à la fois au cache et au bus système (avec un registre entre le bus système et le processeur). Le bus de donnée était lui connecté à la mémoire SRAM et au processeur, avec des MUX/DEMUX pour faire le choix de la source des lectures. [[File:Controleur de cache 82385 pour l'Intel 386.png|centre|vignette|upright=2.5|Contrôleur de cache 82385 pour l'Intel 386]] Le 82385 surveillait ce qui se passait sur le bus et répondait à la place de la RAM pour certaines lectures. C'était un intermédiaire assez passif, qui se contenait de répondre aux succès et défauts en lecture. Le cache était un cache ''write through'' un peu particulier. En cas de succès de cache pour une écriture, le cache met à jour sa ligne de cache et propage l'écriture en mémoire RAM. Par contre, si une écriture fait un défaut de cache, la donnée n'est pas écrite dans le cache. Le seul moyen pour copier une donnée dans le cache était un défaut pour une lecture. Le 82385 pouvait commander soit un cache ''direct mapped'', soit associatif à deux voies. La différence entre les deux est que le 82385 était relié à une mémoire SRAM avec un cache ''direct mapped'', deux SRAM pour deux voies. Pour avoir un cache associatif à deux voies, le 82385 devrait gérer deux signaux ''chip select'' pour activer chaque SRAM/voie suivant les besoins. Il avait précisément quatre signaux CS : deux par SRAM, un pour les lectures, un pour les écritures. Notons que les SRAM utilisées devaient être des SRAM avec un bus de données de 32 bits, des SRAM 16 ou 8 bits ne fonctionnaient pas. Le 82385 rajoutait 4 sorties, pour masquer chaque octet dans ces 32 bits. Le choix entre les deux était le fait d'une entrée : la mettre à 0 indiquait un cache ''direct mapped'', la mettre à 1 forçait un cache à deux voies. Il gérait aussi les accès mémoire non-cacheable, à savoir des accès mémoire qui ne doivent pas être pris en compte par le cache. Il considérait certains accès mémoire comme "à ne pas cacher". Notamment, les accès mémoire à une entré-esortie ne sont pas cachés. Pour rappel, le processeur utilisait un espace d'adressage séparé pour les entrées-sorties, et utilisait donc un bit IO, qui était utilisé par le 82385 pour savoir si l'accès mémoire doit être caché ou non. Il en est de même pour les accès ayant lieu lors d'une interruption, qui ne passent pas par le cache. Mais au-delà de cette inhibition automatique du cache, le 82385 avait une entrée NCA (''Non Cacheable Access'') : le cache était "désactivé" quand cette entrée était à 1. C'est un peu une sorte de ''chip select'' pour le 82385, limitée aux accès mémoire. Cette entrée permettait de programmer des intervalles d'adresse auxquels ne pas répondre, en utilisant des circuits de décodage d'adresse adaptés. Il avait aussi une entrée X16, qui permettait d'identifier les accès soit à un composant 16 bits. De tels accès ne doivent pas être mis en cache, sans doute parce que cela ne collait pas avec la taille des lignes de cache (32 bits). Et cette entrée permettait d'inhiber ces accès 16 bits d'agir sur le cache, en utilisant le bit du bus de commande adéquat. Le 82385 pouvait être intégré dans un système à deux processeurs, voire plus. Pour cela, chaque processeur avait son propre 82385 et sa SRAM rien qu'à lui. Il n'y avait pas de cache partagé entre les deux processeurs. Par contre, les deux caches étaient reliés au même bus système. Pour qu'ils ne se marchent pas sur les pieds, il y avait des circuits d'arbitrage pour gérer l'accès au bus. Un des deux 82385 était mis en mode maitre, l'autre était en mode esclave. Le 82385 maitre pouvait prendre le contrôle du bus, le 82385 esclave devait demander l'autorisation au premier pour accéder au bus système. Le 82385 gérait une forme limitée de cohérence des caches par invalidation. Dès que le 82385 détectait une prise de contrôle du bus par autre chose que le processeur, il surveillait les adresses transmises sur le bus. En cas de succès de cache, la ligne de cache associée était invalidée. Au-delà de ca, le 82385 avait une entrée FLUSH, qui ordonnait une invalidation totale du cache. Si cette entrée est mise à 1, toutes les lignes de cache sont invalidées. Les ''tags'' sont marqués comme invalides, mais les lignes de cache elles-mêmes ne sont pas touchées. ===L'exemple des processeurs Intel de microarchitecture ''Broadwell''=== Un autre exemple est celui du cache L4 des processeurs Broadwell et de quelques processeurs séparés. Ces processeurs ont une organisation en ''chiplet'' où le processeur incorpore plusieurs puces séparées : une puce pour le processeur proprement dit, une puce nommée ''Crystal Well'' pour le cache L4, et une puce IO pour la communication avec la RAM et la carte mère. Le processeur incorporait un cache L4 de 128 mébioctets, composé de mémoire eDRAM, qui était dispersé entre ''Crystal Well'' et les autres puces. Les données du cache L4 étaient dans ''Crystal Well'', alors que les Tags étaient soit dans le processeur lui-même, soit dans la puce IO ! La puce ''Crystal Well'' était une mémoire DRAM adressable tout ce qu'il y a de plus basique, avec cependant quelques optimisations notables. Par exemple, elle avait deux bus séparés pour l'écriture et la lecture. De plus, elle avait une organisation interne avec 128 banques, contre moins d'une dizaine pour la DDR de l'époque et environ 32 banques pour la DDR5 moderne. Elle contenait aussi quelques circuits pour gérer son rôle de mémoire cache, mais rien en ce qui concerne la gestion des tags eux-mêmes. Sur les processeurs de microarchitecture ''Broadwell'', les tags étaient placés dans le CPU et précisément dans le cache L3. À chaque accès mémoire au cache L3, les tags du cache L4 étaient consultés en parallèle. De fait, l'accès au cache L4 était assez rapide, malgré le fait que les données étaient dans une puce à part. Ajoutons à cela que le processeur et ''Crystal Well'' n'avaient pas la même finesse de gravure ni la même technologie de fabrication. Les tags étaient implémentés avec de la SRAM contre la DRAM pour les données, ce qui fait que la consultation des tags était plus rapide que l'accès aux données. Par la suite, dans certains CPU de microarchitecture ''skylake'', les tags ont été déplacés en-dehors du processeur pour finir dans le contrôleur mémoire. En faisant cela, le cache L4 pouvait être utilisé par autre chose que le processeur, et notamment par la carte graphique intégrée au CPU. Avec ''broadwell'', le fait que les tags étaient consultés en cas d'accès au L3 empêchait au GPU intégré de consulter le cache L4. Mais en déplaçant les tags dans le contrôleur mémoire, ce n'est plus le cas vu que la carte graphique a aussi accès au bus mémoire. Par contre, le temps d'accès augmente comparé à la solution précédente. On n'accède pas aux tags du L4 en parallèle du L3 : à la place, il faut consulter les tags du L3, détecter un défaut de cache L3, et ensuite accèder aux tags. ===Les caches RAM-configurables=== Un autre avantage des caches splittés est qu'on peut les modifier pour servir à la fois de mémoire cache, mais aussi de ''local store'', de mémoire RAM de petite taille. Le fonctionnement est assez simple à comprendre. Lors d'un accès au cache, on accède aux tags, puis à la RAM interne au cache. Lors d'un accès au ''local store'', on contourne l'accès au tags et on accède à la RAM interne au cache directement. Il s'agit de la technique du '''cache RAM-configurable''. L'usage de cache RAM-configurable est fréquent sur les cartes graphiques récentes, qui incorporent un ou plusieurs processeurs multicoeurs, dont le cache L1 de données est un cache RAM-configurable. [[File:Hydride cache - local store.png|centre|vignette|upright=2.0|Hydride cache - local store]] ===La compression de cache=== Une autre optimisation permise par les ''phased caches'' est l'implémentation de techniques de '''compression de cache''', qui visent à compresser des lignes de cache. L'intérêt est qu'on peut stocker plus de données dans le cache, à capacité égale. L'inconvénient est qu'on doit compresser/décompresser les lignes de cache, ce qui demande un circuit en plus et allonge les temps d'accès. En effet, le temps mis pour compresser/décompresser une ligne de cache s'ajoute au temps d'accès. Aussi, la compression de cache sert surtout pour les caches de bas niveau dans la hiérarchie mémoire, les gros caches aux temps d'accès assez longs. Une première technique, assez simple à implémenter et peu couteuse en circuit, est celle de la '''compression des lignes de cache nulles'''. Elle compresse uniquement les lignes de cache qui ne contiennent que des zéros. L'idée est qu'on ajoute, dans la mémoire des tags, un bit de contrôle pour chaque ligne de cache appelé le bit ''null''. Il indique si la ligne de cache ne contient que des zéros. Quand on lit une ligne de cache, la mémoire des tags est accédée et on vérifie le bit ''null'' : s'il vaut 1, on n'accède pas à la mémoire cache de données et un multiplexeur envoie un zéro sur le port de lecture. Le bit ''null'' est fixé lors de l'écriture d'une ligne de cache : elle passe dans un comparateur avec zéro relié à la mémoire des tags. La comparaison avec zéro peut se faire en parallèle de l'écriture ou avant (dans ce cas, on n'écrit pas la ligne de cache dans le cache). Les autres techniques de compression de cache permettent de compresser autre chose que des lignes de cache nulles. L'idée est qu'une ligne de cache physique peut par moment mémoriser plusieurs lignes de caches compressées. Par exemple, prenons un cache dont les lignes de cache font 64 octets. Il est possible de compresser deux lignes de cache pour qu'elles fassent chacune 32 octets, et les stocker dans une seule ligne de cache. Les deux lignes de cache auront des tags différents, mais pointeront sur la même ligne de cache physique. Et cela demande d'utiliser un ''phased cache'' dont la mémoire pour les tags est plus grande que la mémoire pour les données. Il n'y a donc plus une bijection entre tags et ligne de cache, mais une relation surjective. Chose qui n'est possible qu'avec un ''phased cache''. De plus, des bits de contrôles associés à chaque ''tag'' indiquent où se trouvent les lignes de cache compressées dans la ligne de cache : est-ce que c'est les 32 octets de poids fort ou de poids faible ? [[File:Compression de cache.png|centre|vignette|upright=2|Compression de cache]] Il ne semble pas que les techniques de compression de cache soient implémentées sur les processeurs modernes. Aucun n'utilise de compression de cache, à ma connaissance. Il faut dire que les techniques connues sont de mauvais compromis : le temps d'accès du cache augmente beaucoup, le cout en circuit pourrait être utilisé pour un cache non-compressé mais plus grand. Et notons que la compression de cache ne marche que si les données peuvent se compresser. Si ce n'est pas le cas, une partie de la mémoire des tags est inutilisée. Une revue de la littérature académique sur la compression de cache est disponible via ce lien, pour les curieux : * [https://inria.hal.science/hal-03285041 Understanding Cache Compression, par Carvalho et Seznec]. ==L'adressage physique ou logique des caches== Le cache utilise les adresses à lire/écrire pour déterminer s'il a une copie de la donnée en son sein. Mais l’interaction entre caches et mémoire virtuelle donne lieu à un petit problème : l'adresse utilisée est-elle une adresse virtuelle/logique ou physique ? La réponse varie suivant le processeur : certains caches utilisent l'adresse virtuelle, tandis que d'autres prennent l'adresse physique. On parle de cache '''virtuellement tagué''' dans le premier cas et de cache '''physiquement tagué''' dans le second. {| |[[File:Cache tagué virtuellement.png|vignette|Cache tagué virtuellement.]] |[[File:Cache tagué physiquement.png|vignette|Cache tagué physiquement.]] |} ===L'accès à un cache physiquement/virtuellement tagué=== La manière d'accéder à un cache dépend de s'il est virtuellement ou physiquement tagué. Il faut utiliser l'adresse virtuelle pour les premiers, physique pour les seconds. Avec un cache virtuellement tagué, l'adresse logique peut être envoyée directement au cache. La MMU ne traduit les adresses que s'il faut accéder à la mémoire RAM. Ces caches sont donc plus rapides. Avec un cache physiquement tagué, le processeur doit traduire l'adresse logique en adresse physique dans la MMU, avant d'accéder au cache. La traduction d'adresse se fait soit en accédant à une table des pages en mémoire RAM, soit en accédant à un cache spécifiquement dédié à accélérer la traduction d'adresse, la TLB (''Translation Lookaside Buffer''). Dans la quasi-totalité des cas, la traduction d'adresse passe par la TLB, ce qui fait qu'elle est raisonnablement rapide. Toujours est-il que chaque accès au cache demande d'accéder à la TLB et de faire la traduction d'adresse avant d'accéder au cache. L'accès est donc plus lent que sur les caches virtuellement tagués, où les accès sont plus directs. [[File:Virtual and Physical addressing.svg|centre|vignette|upright=2|Cache tagué virtuellement versus physiquement tagué.]] ===Les défauts des caches virtuellement tagués=== Les caches physiquement tagués sont moins rapides que les caches virtuellement adressés. Pourtant, les caches virtuellement tagués sont peu fréquents sur les processeurs modernes. Et la raison est assez intéressante : c'est une question d'adresses homonymes et synonymes. ====Les droits d'accès doivent être vérifiés lors d'un accès au cache==== Un premier problème est que la protection mémoire est compliquée avec de tels caches. Rappelons que certaines portions de mémoire sont accessibles seulement en lecture, ou sont interdites en écriture, sont inexécutables, etc. Ces droits d'accès sont gérés par la MMU, qui vérifie pour chaque accès mémoire que l'accès est autorisé. En bypassant la MMU, l'accès au cache virtuellement tagué ne permet pas de faire ces vérifications. Il est possible de charger une donnée en lecture seule dans le cache, mais d'y faire des accès en écriture pour les accès ultérieurs. Les solutions à cela sont multiples. La première consiste à consulter la MMU en parallèle de l'accès au cache. L'accès au cache est alors réalisé de manière spéculative, et est ensuite confirmé/annulé une fois que la MMU a rendu son verdict. Les performances du cache restent alors les mêmes : l'accès à la MMU se fait en parallèle de l'accès au cache, pas avant. Une autre solution est d'ajouter les droits d'accès en question dans la ligne de cache, dans les bits de contrôle situés après le Tag. Chaque accès au cache récupère ces bits de contrôle et vérifie si l'accès est autorisé. L'inconvénient est que les lignes de cache deviennent plus longues, les droits d'accès sont dupliqués entre MMU et cache. Mais si le budget en transistor suit, ce n'est rien d'insurmontable. ====Les adresses homonymes perturbent la gestion du cache==== Pour rappel, une adresse logique homonyme correspond à plusieurs adresses physiques différentes. Elles surviennent quand chaque programme a son propre espace d'adressage. Dans ce cas, une adresse logique correspondra à une adresse physique différente par programme.Une autre manière de voir les choses est qu'il y a en réalité deux adresses homonymes, qui ont la même valeur, mais appartiennent à des espaces d'adressage différentes. Et c'est cette seconde interprétation que nous allons utiliser. Les caches doivent gérer ces adresses homonymes et faire en sorte que la lecture/écriture d'une adresse homonyme se fasse à la bonne adresse physique, dans la bonne ligne de cache. Et autant un cache physiquement tagué n'a aucun problème avec ça, vu qu'il ne gère que des adresses physiques, autant des problèmes surviennent avec les caches virtuellement tagués. Le problème est que les caches virtuellement tagués doivent faire la différence entre deux adresses homonymes de même valeur. Pour corriger ces problèmes, il existe deux grandes méthodes. La première méthode est simple : '''vider les caches''' en changeant de programme. Leur contenu est rapatrié en mémoire RAM, puis les caches sont remis à zéro. Le vidage du cache recopie les lignes de cache ''dirty'' (modifiées) en RAM, puis efface/invalide tout le cache. C'est à cela que servent les instructions ''clean'' et d'invalidation vues plus haut, elles ont été inventées pour cette situation précise. Lorsque le système d'exploitation déclenche une commutation de contexte, à savoir qu'il change le programme en cours d'exécution, le processeur vide tous les caches du processeur. Les interruptions font la même chose, elles vide tous les caches du processeur. Une seconde méthode numérote chaque programme en cours d'exécution, chaque processus. Le numéro attribué est spécifique à chaque processus, ce qui fait qu'il est appelé un '''identifiant de processus CPU'''. Le processeur mémorise l'identifiant du programme en cours d'exécution dans un registre dédié. L'identifiant de processus CPU est utilisé lors des accès mémoire. Chaque ligne de cache contient le numéro de l'espace d'adressage associé, dans son ''tag''. Lors de chaque accès mémoire, l'ID du registre est comparé à l'ID de la ligne de cache accédée, pour vérifier que l'accès mémoire accède à la bonne donnée. Cette méthode n'est pas très économe en termes de transistors. L'usage d'identifiant de processus CPU est clairement meilleure en termes de performance, les commutations de contexte sont plus rapides. Par contre, le budget en transistor est plus important. Un autre défaut de cette méthode est que l'identifiant de processus est généralement codé sur une dizaine de bits, alors que le système d'exploitation utilise des identifiants de processus beaucoup plus larges, de 32 à 64 bits sur les CPU 32/64 bits. L'OS doit gérer la correspondance entre identifiants de processus CPU et ceux de l'OS. Parfois, pour cette raison, les OS n'utilisent pas toujours ce système d'identifiant de processus CPU. ====Les adresses synonymes perturbent aussi la gestion du cache==== La gestion des adresses synonymes est aussi un gros problème sur les caches virtuellement tagués. Pour rappel, il s'agit du cas où des adresses logiques différentes pointent vers la même adresse physique. Typiquement, quand deux programmes se partagent un morceau de mémoire, ce morceau correspondra à des adresses synonymes dans les deux espaces d'adressage. Mais il arrive que l'on ait des adresses synonymes dans le même espace d'adressage, ce n'est pas si rare ! Autant les adresses synonymes ne posent aucun problème avec les caches physiquement tagués, ce n'est pas le cas avec les caches virtuellement adressés. Sur ces caches, deux adresses logiques synonymes vont tomber dans deux lignes de cache différentes. Corriger ce problème demande d'ajouter des circuits annexes pour détecter les adresses synonymes, qui sont vraiment complexes et ont un cout en termes de performance. Aussi, les caches virtuellement tagués sont très peu utilisés sur les processeurs modernes. ===Les caches virtuellement adressés, mais physiquement tagués=== Si les caches physiquement et virtuellement tagués ont des défauts, il existe un intermédiaire qui est un bon compromis entre ces deux extrêmes. Il s'agit des '''caches virtuellement adressés - physiquement tagués''', aussi appelés '''caches pseudo-virtuels'''. Pour comprendre comment ils fonctionnent, précisons que ces caches sont soit des caches ''direct-mapped'', soit des caches associatifs par voie (composés de plusieurs RAM ''direct-mapped'' accédées en parallèle, plusieurs voies). L'accès à ce genre de cache se fait en deux temps : on accède à un ou plusieurs RAM ''direct-mapped'' et on vérifie ensuite les ''Tags'' pour sélectionner la bonne voie. Sur les caches ''direct-mapped'', on n'a qu'une seule RAM ''direct-mapped''. Sur les caches associatifs, on a plusieurs RAM ''direct-mapped'', appelées des voies, qui sont accédées en parallèle. L'accès se fait donc en deux étapes : adresser les RAM ''direct-mapped'' avec un indice, vérifier les ''tags'' avec le reste de l'adresse. Une autre chose à rappeler est que l'adresse logique est composée de deux parties : un numéro de page logique qui indique dans quel page se situe l'adresse, un décalage/''offset'' qui indique la position de l'adresse dans la page. La traduction d'adresse transforme le numéro de page logique en numéro de page physique, mais laisse le décalage intouché. L'idée est d'utiliser le décalage pour adresser les RAM avec le décalage, tandis que le numéro de page sert de ''tag''. Le décalage est découpé en deux lors de l'accès au cache : les bits de poids fort forment l'indice (l'adresse envoyée à la voie), les bits de poids faible donnent la position de l'adresse dans la ligne de cache. L'idée est d'utiliser un numéro de page physique pour les ''tags'', mais d'adresser les voies avec le décalage logique. Les deux servent à des instants différents : vérification des ''tags'' pour l'adresse physique, accès aux voies pour l'adresse logique. Ainsi, le problème des adresses synonymes ou homonymes est résolu par l'utilisation de l'adresse physique pour les tags. Par contre, l'accès au cache est plus rapide, car on utilise l'adresse logique pour la première étape. Le processeur accède à la TLB et récupère l'adresse physique pendant que l'on adresse les voies, les deux sont faits en parallèle, ce qui fait que tout se passe comme si l'accès à la TLB était gratuit. La TLB étant assez rapide comparé au cache, l'adresse physique est disponible quand on doit faire la comparaison avec les ''tags''. [[File:Virtual - Physical - Pseudo Virtual addressing.svg|centre|vignette|upright=2|Adressage pseudo virtuel des caches.]] Il s'agit d'un excellent compromis entre performance et correction des problèmes des adresses synonymes/homonymes. Tous les caches des processeurs haute performance utilisent cette méthode, au moins pour leurs caches L1. Les caches L2 tendent à utiliser des caches physiquement adressés, pour lesquels la latence d'accès est suffisante pour qu'on accède à la TLB en amont. La raison est assez simple à expliquer, elle provient d'une contrainte assez précise sur le calcul de l'indice. La conséquence est qu'un cache ''direct-mapped'' ne peut pas dépasser la taille d'une page, soit 4 kibioctets sur les ordinateurs actuels. Sur les caches associatifs, on peut dépasser cette limite en augmentant le nombre de voies, mais la taille maximale d'une voie reste celle d'une page. Cette contrainte n'est pas trop grave sur les caches de petite taille, dont les caches L1. La plupart d'entre eux ont trouvé un compromis idéal avec moins d'une dizaine de voies par cache, chacun de 4 kibioctets, ce qui donne des caches allant de 16 à 64 kibioctets, soit entre 4 et 16 voies. Par contre, un cache de grande taille doit utiliser un grand nombre de voies, ce qui est peu pratique. Aussi, cette technique de caches pseudo-virtuels n'est pas toujours appliquée sur les caches L2, qui sont physiquement adressés. Il faut dire qu'on accède au cache L2 lors d'un défaut dans le cache L1, et l'adresse physique est disponible à ce moment-là, elle a déjà été récupérée lors de l'accès au cache L1. On peut donc l'utiliser pour adresser le cache L2 sans perte de performance. ==Le remplacement des lignes de cache== Lorsqu'un cache est rempli et qu'on charge une nouvelle donnée dedans, il faut faire de la place pour cette dernière. Dans le cas d'un cache directement adressé, il n'y a rien à faire vu que la ligne de cache à évincer est déterminée lors de la conception du cache. Mais pour les autres caches, la donnée peut aller dans n'importe quelle ligne ou voie. Or, le choix des données à rapatrier en RAM doit être le plus judicieux possible : on doit virer de préférence des données inutiles. Rapatrier une donnée qui sera surement utilisée sous peu est inutile, et il vaudrait mieux supprimer des données qui ne serviront plus ou alors dans longtemps. Il existe différents algorithmes spécialement dédiés à résoudre ce problème efficacement, directement câblés dans les unités de gestion du cache. Certains sont vraiment très complexes, aussi je vais vous présenter quelques algorithmes particulièrement simples. Mais avant de voir ces algorithmes, il faut absolument que je vous parle d'une chose très importante. Quel que soit l'algorithme en question, il choisit la ligne de cache à évincer et recopie son contenu dans la RAM. Ce qui demande d'identifier et de sélectionner une ligne de cache parmi toutes les autres. Pour cela, le circuit de remplacement attribue une adresse chaque ligne de cache ! Vous avez bien vu : chaque ligne de cache est numérotée par une adresse, interne au cache. ===Le remplacement aléatoire=== Premier algorithme : la donnée effacée du cache est choisie au hasard ! C'est contre-intuitif, mais cet algorithme donne des résultats assez honorables, en plus d'utiliser très peu de portes logiques (un générateur de nombres pseudo-aléatoire est un circuit assez simple). Généralement, les défauts de cache sont séparés par un nombre assez important et irrégulier de cycles d'horloge. Dans ces conditions, cette technique donne un bon résultat. ===FIFO : first in, first out=== Avec l'algorithme FIFO, la donnée effacée du cache est la plus ancienne, celle chargée dans le cache avant les autres. Cet algorithme est très simple à implémenter en circuit, concevoir une mémoire de type FIFO n'étant pas très compliqué, comme on l’a vu dans le chapitre dédié à ce type de mémoires. Et on peut dire que dans le cas d'un cache, l'implémentation est encore plus simple et se contente d'un seul registre/compteur. Typiquement, il suffit d'ajouter un registre qui mémorise où se situe la donnée la plus récente. Toute insertion d'une nouvelle donnée se fait à l'adresse suivante, ce qui demande juste d'incrémenter le registre avant d'utiliser son contenu pour l'accès mémoire. [[File:Algorithme FIFO de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme FIFO de remplacement des lignes de cache.]] Cet algorithme possède une petite particularité sur les caches associatifs par voie : en augmentant le nombre d'ensembles, les performances peuvent se dégrader : c'est ce qu'on appelle l''''anomalie de Bélády'''. ===MRU : most recently used=== Avec l'algorithme MRU, la donnée remplacée est celle qui a été utilisée le plus récemment. Cet algorithme s'implémente simplement avec un registre, dans lequel on place le numéro de la dernière ligne de cache utilisée. Cet algorithme de remplacement est très utile quand un programme traverse des tableaux du premier élément jusqu'au dernier : les données du tableau sont rarement réutilisées, rendant le cache inutile. Il est prouvé que dans ces conditions, l'algorithme MRU est optimal. Mais dans toutes les autres conditions, cet algorithme a des performances assez misérables. ===LFU : least frequently used=== Avec l'algorithme LFU, la donnée supprimée est celle qui est utilisée le moins fréquemment. Cet algorithme s'implémente en associant un compteur à chaque ligne de cache, qui est incrémenté à chaque accès mémoire. La ligne la moins récemment utilisée est celle dont le compteur associé a la plus petite valeur. Implémenter cet algorithme prend pas mal de transistors, car il faut rajouter autant de compteurs qu'il y a de lignes de cache, en plus d'un circuit pour comparer les compteurs et d'un encodeur. [[File:Algorithme LFU de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme LFU de remplacement des lignes de cache]] ===LRU : least recently used=== Avec l'algorithme LRU, la donnée remplacée est celle qui a été utilisée le moins récemment. Cet algorithme se base sur le principe de localité temporelle, qui stipule qu'une donnée accédée récemment a de fortes chances d'être réutilisée dans un futur proche. Et inversement, la donnée la moins récemment utilisée du cache est celle qui a le plus de chance de ne servir à rien dans le futur. Autant la supprimer en priorité pour faire de la place à des données potentiellement utiles. Implémenter l'algorithme LRU peut se faire de différentes manières, qui ont pour point commun d'enregistrer les accès au cache pour en déduire la ligne la moins récemment accédée. La manière la plus simple demande d'utiliser un compteur pour chaque ligne de mémoire cache, un peu comme le LFU. La différence avec le LFU est que le compteur n'est pas incrémenté lors d'un accès mémoire. À la place, ce compteur est incrémenté régulièrement, chaque incrémentation ayant lieu en même temps pour tous les compteurs. Quand un bloc est chargé dans le cache, ce compteur est mis à zéro. Quand une ligne de cache doit être remplacée, un circuit va vérifier la valeur de tous les compteurs : la ligne LRU (la moins récemment utilisée), est celle dont le compteur a la valeur la plus haute. Le circuit est composé d'un paquet de comparateurs, et d'un encodeur, comme pour l'agorithme LFU. ===Les approximations du LRU=== Implémenter le LRU demande un nombre de transistors proportionnel au carré du nombre de lignes de cache. Autant dire que le LRU devient impraticable sur de gros caches. Ce qui fait que les processeurs modernes implémentent des variantes du LRU, moins couteuses en transistors, qui donnent un résultat approximativement semblable au LRU. En clair, ils ne sélectionnent pas toujours la ligne de cache la moins récemment utilisée, mais une ligne de cache parmi les moins récemment utilisées. Ce n'est pas un problème si grave que cela car les lignes les moins récemment utilisées ont toutes assez peu de chance d'être utilisées dans le futur. Entre choisir de remplacer une ligne qui a 0,5 % de chances d'être utilisée dans le futur et une autre qui a une chance de seulement 1 %, la différence est négligeable en termes de taux de succès. Mais les gains en termes de circuits ou de temps d'accès au cache de ces algorithmes sont très intéressants. L'algorithme le plus simple consiste à couper le cache (ou chaque voie s'il est associatif) en plusieurs sections. L'algorithme détermine la section la moins récemment utilisée, avant de choisir aléatoirement une ligne de cache dans cette section. Pour implémenter cet algorithme, il nous suffit d'un registre qui mémorise le morceau le moins récemment utilisé, et d'un circuit qui choisit aléatoirement une ligne de cache. Cette technique s'adapte particulièrement bien avec des caches associatifs à voies : il suffit d'utiliser autant de morceaux que de voies. Autre algorithme, un peu plus efficace : le '''pseudo-LRU de type M'''. Cet algorithme attribue un bit à chaque ligne de cache, bit qui sert à indiquer de façon approximative si la ligne de cache associée est une candidate pour un remplacement ou non. Il vaut 1 si la ligne n'est pas une candidate pour un remplacement et zéro sinon. Le bit est mis à 1 lorsque la ligne de cache associée est lue ou écrite. Évidemment, au fil du temps, toutes les lignes du cache finiront par avoir leur bit à 1. Lorsque cela arrive, l'algorithme remet tous les bits à zéro, sauf pour la dernière ligne de cache accédée. L'idée derrière cet algorithme est d'encercler la ligne de cache la moins récemment utilisée au fur et à mesure des accès. L'encerclement commence lorsque l'on remet tous les bits associés aux lignes de cache à 0, sauf pour la ligne accédée en dernier. Au fur et à mesure des accès, l'étau se resserre autour de la ligne de cache la moins récemment utilisée. Après un nombre suffisant d'accès, l'algorithme donne une estimation particulièrement fiable. Et comme les remplacements de lignes de cache sont rares comparés aux accès aux lignes, cet algorithme finit par donner une bonne estimation avant qu'on ait besoin d'effectuer un remplacement. Le dernier algorithme d'approximation, le '''PLURt''', se base sur ce qu'on appelle un arbre de décision. Il a besoin de n − 1 bits pour déterminer la ligne LRU. Ces bits doivent être organisés en arbre, comme illustré plus bas. Chacun de ces bits sert à dire : le LRU est à ma droite ou à ma gauche : il est à gauche si je vaux 0, et à droite si je vaux 1. Trouver le LRU se fait en traversant cet arbre, et en interprétant les bits un par un. Au fur et à mesure des lectures, les bits sont mis à jour dans cet arbre, et pointent plus ou moins bien sur le LRU. La mise à jour des bits s'effectue lors des lectures et écritures : quand une ligne est lue ou écrite, elle n'est pas la ligne LRU. Pour l'indiquer, les bits à 1 qui pointent vers la ligne de cache sont mis à 0 lors de la lecture ou écriture. {| |[[File:Organisation des bits avec l'algorithme PLURt.jpg|vignette|Organisation des bits avec l'algorithme PLURt.]] |[[File:Ligne de cache pointée par les bits de l'algorithme.png|vignette|Ligne de cache pointée par les bits de l'algorithme.]] |} ===LRU amélioré=== L'algorithme LRU, ainsi que ses variantes approximatives, sont très efficaces tant que le programme respecte relativement bien la localité temporelle. Par contre, Le LRU se comporte assez mal dans les circonstances ou la localité temporelle est mauvaise mais où la localité spatiale est respectée, le cas le plus emblématique étant le parcours d'un tableau. Pour résoudre ce problème, des variantes du LRU existent. Une variante très connue, l''''algorithme 2Q''', utilise deux caches : un cache FIFO pour les données accédées une seule fois et un second cache LRU. Évidemment, les données lues une seconde fois sont migrées du cache FIFO vers le cache LRU, ce qui n'est pas très pratique. Les processeurs n'utilisent donc pas cette technique, mais celle-ci est utilisée dans les caches de disque dur. D'autres variantes du LRU combinent plusieurs algorithmes à la fois et vont choisir lequel de ces algorithmes est le plus adapté à la situation. Notre cache pourra ainsi détecter s’il vaut mieux utiliser du MRU, du LRU, ou du LFU suivant la situation. ==Les écritures dans le cache : gestion et optimisations== Les écritures se font à une adresse mémoire bien précise, qui peut ou non être chargée dans le cache. Si la donnée à écrire est chargée dans le cache, elle est modifiée directement dans le cache, mais elle ne l'est pas forcément en mémoire RAM. Suivant le processeur, les écritures sont ou non propagées en mémoire RAM. Il existe deux stratégies d'écritures, appelées respectivement le ''write-back'' et le ''write-through''. Avec un cache ''write-back'', si la donnée à mettre à jour est présente dans le cache, on écrit dans celui-ci sans écrire dans la mémoire RAM. Dans ces conditions, une donnée n'est enregistrée en mémoire que si celle-ci quitte le cache, ce qui évite de nombreuses écritures mémoires inutiles. [[File:Cache write-through.png|centre|vignette|upright=2|Cache write-through.]] Avec les caches '''Write-Through''', toute écriture dans le cache est propagée en RAM. Cette stratégie augmente le nombre d'écritures dans la mémoire RAM, ce qui peut saturer le bus reliant le processeur à la mémoire. Les performances de ces caches sont donc légèrement moins bonnes que pour les caches ''write back''. Par contre, ils sont utiles dans les architectures avec plusieurs processeurs, comme nous le verrons dans les chapitres sur les architectures multiprocesseurs. [[File:Cache write-back.png|centre|vignette|upright=2|Cache write-back.]] ===Les caches ''Write-through''=== Sans optimisation particulière, on ne peut écrire dans un cache ''write-through'' pendant qu'une écriture en RAM a lieu en même temps : cela forcerait à effectuer deux écritures simultanées, en comptant celle imposée par l'écriture dans le cache. Pour éviter cela, certains caches ''write-through'' intègrent un '''tampon d’écriture''', qui sert de file d'attente pour les écritures en RAM. C'est une mémoire FIFO dans laquelle on place temporairement les données à écrire en RAM, où elles attendent en attendant que la RAM soit libre. Grâce à lui, le processeur peut écrire dans un cache même si d'autres écritures sont en attente dans le tampon d'écriture. Par souci d'efficacité, des écritures à la même adresse en attente dans le tampon d’écriture sont fusionnées en une seule. Cela fait un peu de place dans le tampon d’écriture, et lui permet d'accumuler plus d'écritures avant de devoir bloquer le cache. Il est aussi possible de fusionner des écritures à adresses consécutives de la mémoire en une seule écriture en rafales. Dans les deux cas, on parle de '''combinaison d'écriture'''. Mais la technique du tampon d'écriture a cependant un léger défaut qui se manifeste dans une situation bien précise : quand le processeur veut lire une donnée en attente dans le tampon d’écriture. La première manière de gérer cette situation est de mettre en attente la lecture tant que la donnée n'a pas été écrite en mémoire RAM. On peut aussi lire la donnée directement dans le tampon d'écriture, cette optimisation portant le nom de '''''store-to-load forwading'''''. Dans tous les cas, il faut détecter le cas où une lecture accède à une donnée dans le tampon d'écriture. À chaque lecture, l'adresse à lire est envoyée au tampon d'écriture, qui vérifie si une écriture en attente se fait à cette adresse. Pour cela, le tampon d’écriture doit être un cache, dont chaque entrée mémorise une écriture. Chaque ligne de cache contient la donnée à écrire, et le tag de la ligne de cache contient l'adresse où écrire la donnée. Notons que cache d'écriture a une politique de remplacement de type FIFO, le tampon d'écriture non-optimisé étant une mémoire FIFO. ===Les caches ''Write-back''=== Les caches ''write-back'' ont beau avoir des performances supérieures à celles des caches ''write-through'', il existe des optimisations qui permettent d'améliorer leurs performances. Ces optimisations consistent à ajouter des caches spécialisés à côté du cache proprement dit. Ces caches permettent de mémoriser des données qui sont éliminées du cache par les algorithmes de remplacement de ligne cache, sans pour autant faire une écriture en RAM. En suivant la procédure habituelle de remplacement des lignes de cache, on doit rapatrier la ligne en RAM avant d'en charger une nouvelle. On peut améliorer la situation en faisant l'inverse : on charge la nouvelle ligne pendant que l'ancienne donnée est rapatriée en RAM. Ainsi, la nouvelle ligne est disponible plus tôt pour le processeur, diminuant son temps d'attente. Pour implémenter cette technique, on doit mémoriser l'ancienne ligne de cache temporairement dans un '''cache d’éviction''' (ou ''write-back buffer''). [[File:Cache d’éviction.png|centre|vignette|upright=2|Cache d’éviction]] Les caches directement adressés ou associatifs par voie possèdent aussi un tampon d’écriture amélioré. Pour limiter les défauts par conflit de ces caches, des scientifiques ont eu l'idée d'insérer un cache pour stocker les données virées du cache. En faisant ainsi, si une donnée est virée du cache, on peut alors la retrouver dans ce cache spécialisé. Ce cache s'appelle le '''cache de victime'''. Ce cache de victime est géré par un algorithme de suppression des lignes de cache de type FIFO. Petit détail : ce cache utilise un tag légèrement plus long que celui du cache directement adressé au-dessus de lui. L'index de la ligne de cache doit en effet être contenu dans le tag du cache de victime, pour bien distinguer deux adresses différentes, qui iraient dans la même ligne du cache juste au-dessus. [[File:Victim Cache Implementation Example.svg|centre|vignette|upright=1|Cache de victime.]] ===La configuration du fonctionnement du cache=== Sur de nombreux processeurs, il est possible de configurer la mémoire cache pour qu'elle fonctionne soit en mode ''write-back'', soit en mode ''write-through''. Pour cela, les processeurs modernes incorporent des '''registres de configuration du cache'''. Le terme ''registre de configuration du cache'' est assez transparent et indique bien quel est leur rôle. Ils configurent comment le cache est utilisé et permettent notamment de configurer le cache pour dire s'il doit fonctionner en mode ''write-back'' ou ''write-through''. Ils permettent aussi d'activer ou de désactiver la combinaison sur écriture. Les registres en question sont configurés soit par le BIOS, soit par le système d'exploitation. Ce sont des registres protégés, que les applications ne peuvent pas configurer, elles n'en ont pas le droit. Typiquement, ils ne sont accessibles en écriture qu'en mode noyau. Sur les processeurs x86, les registres de configuration du cache sont appelés des '''''Memory type range registers''''' (''MTRRs''). Les MTRRs sont assez nombreux, et il y a notamment une différence entre mode réel et protégé. Si vous vous souvenez des chapitres sur le mode d'adressage et la mémoire virtuelle, vous vous souvenez que les processeurs x86 incorporent plusieurs modes de fonctionnement. En mode réel, le processeur ne peut adresser qu'un mébioctet de RAM, avec un système de segmentation particulier. En mode protégé, le processeur peut adresser toute la mémoire et la segmentation fonctionne différemment, quand elle n'est pas simplement désactivée. Les MTRRs sont séparés en deux : ceux pour le mode réel, ceux pour le mode protégé. Les MTRRs fixes sont ceux qui configurent le cache en mode réel, ils étaient utilisés pour gérer l'accès au BIOS, à la mémoire VGA de la carte graphique, et quelques autres accès aux entrées-sorties basiques gérées nativement par le BIOS. Pour le mode protégé, les processeurs au-delà du 386 incorporent des MTRRs variables, qui servent pour les autres entrées-sorties en général, notamment les périphériques PCI, la mémoire vidéo de la carte graphique, et j'en passe. De nos jours, les registres de configuration du cache sont désuets et cette fonctionnalité est gérée directement par la mémoire virtuelle. La table des pages contient, pour chaque page mémoire, des bits de contrôle qui disent si la page mémoire est cacheable ou non. Le contournement de cache est alors géré par le système de mémoire virtuelle, le cache de TLB et tout ce qui va avec. ===L’allocation sur écriture=== Que faire quand une écriture modifie une donnée qui n'est pas dans le cache ? Doit-on écrire la donnée dans le cache, ou non ? Si la donnée est écrite dans le cache, on dit que le cache fait une '''allocation sur l'écriture''' (ou ''write-allocate''). Certains caches effectuent une telle allocation sur écriture, mais d'autres ne le font pas ou du moins pas systématiquement. L’allocation sur écriture peut se décliner en deux sous-catégories : le '''chargement à la demande''' et l''''écriture immédiate'''. Dans le premier cas, on charge la donnée à modifier dans le cache, et on la remplace avec la donnée écrite. Dans l'écriture immédiate, l'écriture a lieu directement dans le cache et la donnée à modifier n'est pas chargée dans le cache. Évidemment, seule une portion de la ligne de cache contient la donnée écrite (valide), et le reste contient des données invalides. Le cache doit savoir quelles sont les portions du cache qui sont valides : cela demande d'utiliser un ''sector cache''. [[File:Write-back with write-allocation.svg|centre|vignette|upright=2|Cache Write-back avec allocation sur écriture.]] Sans allocation sur écriture, l'écriture est transférée directement aux niveaux de cache inférieurs ou à la mémoire si la donnée à modifier n'est pas dans le cache. Certains caches de ce genre utilisent une petite optimisation : lors de toute écriture, ils supposent que l'écriture donnera un succès de cache. Si c'est le cas, la ligne de cache qui contient la donnée est mise à jour avec la donnée à écrire. Mais si ce n'est pas le cas, la ligne de cache est invalidée, et l'écriture est transférée directement à la mémoire ou aux niveaux de cache inférieurs. [[File:Write-through with no-write-allocation.svg|centre|vignette|upright=2|Cache Write-through sans allocation sur écriture.]] ===La cohérence des caches=== Il arrive parfois que la mémoire d'un ordinateur soit mise à jour, sans que les modifications soient répercutées dans les mémoires cache. Dans ce cas, le cache contient une donnée périmée. Or, un processeur doit toujours éviter de se retrouver avec une donnée périmée et doit toujours avoir la valeur correcte dans ses caches : cela s'appelle la '''cohérence des caches'''. Il est possible de se retrouver avec des valeurs périmées dans le cache sur les ordinateurs avec plusieurs processeurs, ou si un périphérique écrit en RAM, les modifications ne sont pas répercutées automatiquement dans les mémoires cache. Pour résoudre ce problème, on peut interdire de charger dans le cache des données stockées dans les zones de la mémoire dédiées aux périphériques. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires cache. Autre solution : utiliser le fait que les périphériques déclenchent une interruption matérielle pour laisser le contrôleur DMA accéder à la mémoire. Dans ce cas, il suffit de vider les caches à chaque interruption matérielle. Le processeur peut le faire automatiquement, ou fournir des instructions pour. ==Le ''cache bypassing'' : contourner le cache== Dans certaines situations, le cache n'est pas utilisé pour certains accès mémoire. Diverses techniques permettent en effet d'effectuer des accès mémoire qui contournent le cache, qui ne passent pas par le cache. Ils sont utilisés quand l'accès en cache fait que des instructions normales ne fonctionnent pas. Par exemple, de tels accès directs à la RAM sont notamment utilisés pour l'implémentation d'instructions atomiques, une classe d'instructions spécifiques utilisées sur les processeurs multicœurs, dont nous parlerons dans plusieurs chapitres. Mais ils sont aussi utilisés pour l'accès aux périphériques, ce que nous allons voir maintenant. ===Accéder aux périphériques demande de contourner le cache=== Pour rappel, un périphérique (au sens d'entrée-sortie) contient des registres d’interfaçage qui ont une adresse au même titre que les cases mémoire. Un périphérique peut à tout instant modifier ses registres d’interfaçage, ce qui se répercute automatiquement dans l'espace d'adressage, mais rien de tout cela n'est transmis au cache. Si les accès aux périphériques passaient par l'intermédiaire du cache, on aurait droit à des problèmes. On aurait encore une fois droit à des problèmes de cohérence des caches. Le problème est géré différemment suivant que l'on utilise un espace d'adressage séparé ou des entrées-sorties mappées en mémoire. La solution est que les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache. Cela demande d'adapter le cache et le processeur. L'implémentation exacte dépend de comment sont adressés les périphériques. Pour rappel, il y a deux solutions pour adresser les périphériques : soit les périphériques disposent d'un espace d'adressage séparé de celui de la mémoire, soit il y un espace d'adressage unique partagé entre processeur et mémoire. Les deux cas donnent des solutions différentes. Avec un espace d'adressage séparé, l'espace d'adressage des périphériques n'est pas caché : aucun accès dans cet espace d'adressage ne passe par le cache. La mémoire cache n'est utilisée que pour l'espace d'adressage des mémoires, rien d'autre. C'est de loin le cas le plus simple : il suffit de concevoir le processeur pour. Il dispose d'instructions séparées pour les accès aux registres d’interfaçage et à la RAM/ROM, les premières ne passent pas par le cache, les autres si. Avec des entrées-sorties mappées en mémoire, la même solution est utilisée, mais dans une version un peu différente. Là encore, les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache, si on veut qu'ils marchent comme ils le doivent. Cela demande d'adapter le cache et le matériel pour que accès aux périphériques mappés en mémoire contournent le cache. Des adresses, voire des zones entières de la mémoire, sont marquées comme étant non-cachables. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires caches. Là encore, le processeur doit être prévu pour : on doit pouvoir le configurer de manière à marquer certaines zones de la RAM comme non-cacheable. Reste qu'il faut marquer des régions de la RAM comme non-cacheable. Pour cela, on améliore les registres de configuration du cache, vus plus haut, afin qu'ils permettent de configurer certaines portions de la RAM pour préciser qu'elles ne doivent pas être mises en cache, qu'il faut activer le contournement de cache pour celles-ci. ===Contourner le cache pour des raisons de performance=== Il arrive que des données avec une faible localité soient chargées dans le cache inutilement. Or, il vaut mieux que ces données transitent directement entre le processeur et la mémoire, sans passer par l'intermédiaire du cache. Pour cela, le processeur peut fournir des instructions d'accès mémoire qui ne passent pas par le cache, à côté d'instructions normales. De telle instructions sont appelées des '''instructions mémoire non-temporelles'''. Non-temporelle, dans le sens : pas de localité temporelle (c.a.d que les données ne seront pas réutilisées plus tard). Mais il existe aussi des techniques matérielles, où le cache détecte à l'exécution les lectures qui gagnent à contourner le cache. La dernière méthode demande d'identifier les instructions à l'origine des défauts de cache, le processeur accédant directement à la RAM quand une telle instruction est détectée. Si une instruction d'accès mémoire fait trop de défauts de cache, c'est signe qu'elle gagne à contourner le cache. L'idée est de mémoriser, pour chaque instruction d'accès mémoire, un historique de ses défauts de cache. Il existe plusieurs méthodes pour cela, mais toutes demandent d'ajouter de quoi mémoriser l'historique des défauts de cache des instructions. L'historique est mémorisé dans une mémoire appelée la '''table d’historique des défauts de lecture''' (''load miss history table''), qui est souvent un cache. L'historique en question est, dans sa version la plus simple, un compteur de quelques bits incrémenté à chaque succès de cache et décrémenté à chaque défaut de cache, qui indique si l'instruction a en moyenne fait plus de défauts ou de succès de cache. La table associe le ''program counter'' d'une instruction mémoire à cet historique. À la première exécution d'une instruction d'accès mémoire, une entrée de cette table est réservée pour l'instruction. Lors des accès ultérieurs, le processeur récupérer les informations associées et décide s'il faut contourner le cache ou non. ==La hiérarchie mémoire des caches== [[File:Cache Hierarchy.png|vignette|Hiérarchie de caches]] On pourrait croire qu'un seul cache est largement suffisant pour compenser la lenteur de la mémoire. Hélas, les processeurs sont devenus tellement rapides que les caches sont eux-mêmes très lents ! Pour rappel, plus une mémoire peut contenir de données, plus elle est lente. Et les caches ne sont pas épargnés. Si on devait utiliser un seul cache, celui-ci serait très gros et donc trop lent. La situation qu'on cherche à éviter avec la mémoire RAM revient de plus belle. Même problème, même solution : si on a décidé de diviser la mémoire principale en plusieurs mémoires de taille et de vitesse différentes, on peut bien faire la même chose avec la mémoire cache. Depuis environ une vingtaine d'années, un processeur contient plusieurs caches de capacités très différentes : les caches L1, L2 et parfois un cache L3. Certains de ces caches sont petits, mais très rapides : c'est ceux auxquels on va accéder en priorité. Viennent ensuite d'autres caches, de taille variable, mais plus lents. Les processeurs ont donc une hiérarchie de caches qui se fait de plus en plus complexe avec le temps. Cette hiérarchie est composée de plusieurs niveaux de cache, qui vont des niveaux inférieurs proches de la mémoire RAM à des niveaux supérieurs proches du processeur. Plus on monte vers les niveaux supérieurs, plus les caches sont petits et rapides. Un accès mémoire dans une hiérarchie de cache fonctionne comme suit : on commence par vérifier si la donnée recherchée est dans le cache le plus rapide, à savoir le cache L1. Si c'est le cas,n on la charge depuis ce cache directement. Si elle n’y est pas, on vérifie si elle est dans le cache de niveau supérieur, le cache L2. Et rebelote ! Si elle n'y est pas, on vérifie le cache du niveau supérieur. Et on répète cette opération, jusqu’à avoir vérifié tous les caches. Si la donnée n'est dans aucun cache, on doit alors aller chercher la donnée en mémoire. [[File:Hiérarchie de caches.png|centre|vignette|upright=2|Hiérarchie de caches]] Il y a des différences assez notables entre chaque niveau de cache. Par exemple, les différents niveaux de cache n'ont pas forcément les mêmes politiques de remplacement des lignes de cache. Le cache L1 a généralement une politique de remplacement simple, très rapide, mais peu efficace. De même, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Le cache le plus proche de la mémoire est appelé le '''cache de dernier niveau''', ''Last Level Cache'' en anglais. Il a parfois des caractéristiques totalement différentes des autres caches. Par exemple, sur les processeurs multicoeurs, le cache L3 n'est techniquement pas dans le cœur, mais fait partie d'un ensemble de circuits reliés, comme le contrôleur mémoire ou l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc. ===Les caches exclusifs et inclusifs=== Notons que du point de vue de cette vérification, il faut distinguer les caches inclusifs et exclusifs. Avec les caches inclusifs, si une donnée est présente dans un cache, alors elle est présente dans les caches des niveaux inférieurs, ce qui implique l'existence de données en doublon dans plusieurs niveaux de cache. À l'opposé, les caches exclusifs font que toute donnée est présente dans un seul cache, pas les autres. Il existe aussi des caches qui ne sont ni inclusifs, ni exclusifs. Sur ces caches, chaque niveau de cache gère lui-même ses données, sans se préoccuper du contenu des autres caches. Pas besoin de mettre à jour les niveaux de cache antérieurs en cas de mise à jour de son contenu, ou en cas d'éviction d'une ligne de cache. La conception de tels caches est bien plus simple. Dans les '''caches exclusifs''', le contenu d'un cache n'est pas recopié dans le cache de niveau inférieur. Il n'y a pas de donnée en double et on utilise 100 % de la capacité du cache, ce qui améliore le taux de succès. Par contre, le temps d'accès est un peu plus long. La raison est que si une donnée n'est pas dans le cache L1, on doit vérifier l'intégralité du cache L2, puis du cache L3. De plus, assurer qu'une donnée n'est présente que dans un seul cache nécessite aux différents niveaux de caches de communiquer entre eux pour garantir que l'on a pas de copies en trop d'une ligne de cache, ce qui peut prendre du temps. [[File:Caches exclusifs.png|centre|vignette|upright=2|Caches exclusifs]] Dans le cas des '''caches inclusifs''', le contenu d'un cache est recopié dans les caches de niveau inférieur. Par exemple, le cache L1 est recopié dans le cache L2 et éventuellement dans le cache L3. Ce genre de cache a un avantage : le temps d'accès à une donnée est plus faible. La raison est qu'il ne faut pas vérifier tout un cache, mais seulement la partie qui ne contient pas de donnée en doublon. Par exemple, si la donnée voulue n'est pas dans le cache L1, on n'est pas obligé de vérifier la partie du cache L2 qui contient la copie du L1. Ainsi, pas besoin de vérifier certaines portions du cache, ce qui est plus rapide et permet de simplifier les circuits de vérification. En contrepartie, l'inclusion fait que qu'une partie du cache contient des copies inutiles, comme si le cache était plus petit. De plus, maintenir l'inclusion est compliqué et demande des circuits en plus et/ou des échanges de données entre caches. [[File:Caches inclusifs.png|centre|vignette|upright=2|Caches inclusifs]] Maintenir l'inclusion demande de respecter des contraintes assez fortes, ce qui ne se fait pas facilement. Premièrement, toute donnée chargée dans un cache doit aussi l'être dans les caches de niveau inférieur. Ensuite, quand une donnée est présente dans un cache, elle doit être maintenue dans les niveaux de cache inférieurs. De plus, toute donnée effacée d'un cache doit être effacée des niveaux de cache supérieurs : si une donnée quitte le cache L2, elle doit être effacée du L1. Ces trois contraintes posent des problèmes si chaque cache décide du remplacement des lignes de cache en utilisant un algorithme comme LRU, LFU, MRU, ou autre, qui utilise l'historique des accès. En effet, dans ce cas, le cache décide de remplacer les lignes de cache selon l'historique des accès, historique qui varie suivant chaque niveau de cache. Par exemple, une donnée rarement utilisée dans le L2 peut parfaitement être très fréquemment utilisée dans le L1 : la donnée sera alors remplacée dans le L2, mais sera maintenue dans le L1. On observe aussi des problèmes quand il existe plusieurs caches à un seul niveau : chaque cache peut remplacer les lignes de cache d'une manière indépendante des autres caches du même niveau, donnant lieu au même type de problème. Pour maintenir l'inclusion, les caches doivent se transmettre des informations qui permettent de maintenir l'inclusion. Par exemple, les caches de niveaux inférieurs doivent prévenir les niveaux de cache supérieurs quand ils remplacent une ligne de cache. De plus, toute mise à jour dans un cache doit être répercutée dans les niveaux de cache inférieurs et/ou supérieurs. On doit donc transférer des informations de mise à jour entre les différents niveaux de cache. Généralement, le contenu des caches d'instruction n'est pas inclus dans les caches de niveau inférieurs, afin d'éviter que les instructions et les données se marchent sur les pieds. Enfin, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Dans ce cas, l'inclusion est plus difficile à maintenir, pour des raisons assez techniques. ===Les caches eDRAM, sur la carte mère et autres=== D'ordinaire, les mémoires caches sont intégrées au processeur, à savoir que cache et CPU sont dans le même circuit imprimé. Les caches sont donc fabriqués avec de la SRAM, seule forme de mémoire qu'on peut implémenter dans un circuit intégré. Intégrer tous les caches dans le processeur est une solution et efficace. Mais certains processeurs ont procédé autrement. [[File:Cache-on-a-stick module.jpg|vignette|Cache-on-a-stick module]] Des processeurs assez anciens incorporaient un cache L1 dans le processeur, mais plaçaient un cache L2 sur la carte mère. Le cache était clippé sur un connecteur sur la carte mère, un peu comme le sont les barrettes de mémoire. On parlait alors de '''''Cache on a stick''''' (COAST). On aurait pu s'attendre à ce que de tels caches soient en DRAM, vu qu'ils sont placés sur des barrettes de RAM, mais la ressemblance avec la mémoire RAM principale s'arrête là. Le cache était fabriqué en mémoire SRAM, même s'il est en théorie possible de faire de tels caches avec de la DRAM. Les premiers processeurs avec un cache faisaient ainsi, au début des années 90. Il a été introduiot sur les processeurs Motorola, et a été utilisé sur les IBM PC et les Macintosh de l'époque. Les ordinateurs Macintosh utilisaient de tels caches, pour la pluaprt des modèles. Pour ce qui est des PC, les premiers processeurs x86 faisaient pareil, notamment les processeurs Intel. Le 486, le Pentium et le Pentium 2 utilisaient des ''Cache on a stick''. L'avantage est que cela permettait de mettre plus de cache, à une époque où les circuits étaient limités en transistors. De plus, cela permettait au consommateur de choisir quelle quantité de cache il voulait, selon ses finances. Il était possible de laisser le processeur fonctionner soit sans mémoire cache, soit avec un cache de 256 Kibioctets, de 512 Kibioctets, etc. Il était possible d'upgrader le cache si besoin. Pour les CPU Intel, le cache était connecté sur le bus système, au même titre que la mémoire RAM et les entrées-sorties. Il faut dire que les processeurs de l'époque utilisaient un bus système et n'avaient pas de bus mémoire dédié. Mais en théorie, rien n’empêche de connecter le cache sur un bus mémoire dédié. Toujours est-il que les lectures et écritures étaient propagées à la fois dans le cache et la RAM. Les écritures se faisaient dans les deux, systématiquement dans la RAM, mais aussi dans le cache en cas de succès de cache. Les lectures étaient servies soit par le cache en cas de succès de cache, soit par la RAM en cas de défaut de cache. Si le cache répondait en premier, la transaction sur le bus se terminait précocement et l'accès en RAM était abandonné. [[File:Intel486 Иерархия памяти.png|centre|vignette|upright=2.5|Intel486 : le cache était connecté sur le bus système.]] À l'inverse, certains processeurs possédaient un cache fabriqué en mémoire DRAM, et plus précisément avec de la mémoire eDRAM. Le cache n'était pas intégré dans le même circuit imprimé que le processeur, mais profitait d'une architecture en ''chiplet''. Pour rappel, cela veut dire que le processeur est en réalité composé de plusieurs circuits intégré séparés, mais interconnectés et soudés sur un même PCB carré. Avec un cache en eDRAM, le cache avait son propre circuit intégré, séparé du circuit intégré du processeur ou du circuit intégré pour le contrôleur mémoire/IO. Un exemple est celui du cache des processeurs Intel de microarchitecture Broadwell, vus dans ce chapitre dans la section sur les caches splittés. Les tags étaient intégrés dans le circuit intégré du processeur, mais les données étaient mémorisées dans une puce d'eDRAM séparée. La puce eDRAM correspondait en réalité à une DRAM adressable qui servait de DRAM pour les données et mémorisaient les voies du cache. ==Les caches adressés par somme et hashés== Les caches adressés par somme sont optimisés pour incorporer certains calculs d'adresse directement dans le cache lui-même. Pour rappel, certains modes d'adressage impliquent un calcul d'adresse, qui ajoute une constante à une adresse de base. Généralement, l'adresse de base est l'adresse d'un tableau ou d'une structure, et la constante ajoutée indique la position de la donnée dans le tableau/la structure. Les caches hashés et les caches adressés par somme permettent de faire l'addition directement dans la mémoire cache. Voyons d'abord les caches hashés, avant de passer aux caches adressés par somme. Sur les '''caches hashés''', l'addition est remplacée par une autre opération, par exemple des opérations bit à bit du style XOR, AND ou OR, etc. Seulement, utiliser des opérations bit à bit pose un problème : il arrive que deux couples Adresse/décalage donnent le même résultat. Par exemple, le couple Adresse/décalage 11101111/0001 donnera la même adresse que le couple 11110000/0000. Dit autrement, deux adresses censées être différentes (après application du décalage) sont en réalité attribuées à la même ligne de cache. Il est toutefois possible de gérer ces situations, mais cela demande des astuces de haute volée pour faire fonctionner la mémoire cache correctement. Sur les '''caches adressés par somme''', le décodeur est modifié pour se passer de l'addition. Pour comprendre comment, il faut rappeler qu'un décodeur normal est composé de comparateurs, qui vérifient si l'entrée est égale à une constante bien précise. Sur un cache ordinaire, l'addition est faite séparément du décodage des adresses par le cache, dans l'unité de calcul ou dans l'unité de génération d'adresse. [[File:Non sum adressed cache.png|centre|vignette|upright=2|Cache normal.]] Mais les caches adressés par somme modifient le décodeur, qui est alors composé de comparateurs qui testent si la somme adresse + décalage est égale à une constante. [[File:Cache adressé par somme.png|centre|vignette|upright=2|Cache adressé par somme.]] Chaque circuit du décodeur fait le test suivant, avec K une constante qui dépend du circuit : : <math>A + B = K</math> Ce qui est équivalent à faire le test suivant : : <math>A + B - K = 0</math> En complément à deux, on a <math>- K = \overline{K} + 1</math>. En injectant dans l'équation précédente, on a : : <math>A + B + \overline{K} + 1 = 0</math> En réorganisant les termes, on a : : <math>A + B + \overline{K} = - 1</math> Il suffit d'utiliser un additionneur ''carry-save'' pour faire l'addition des trois termes. Rappelons qu'un tel additionneur fournit deux résultats en sortie : une somme calculée sans propager les retenues et les retenues en question. Notons que les retenues sont à décaler d'un cran, vu qu'elles sont censées s'appliquer à la colonne suivante. En notant la somme S et les retenues R, on a: : <math>S + (R << 1) = - 1 </math>, le décalage d'un cran à gauche étant noté <math><< 1</math>. Ensuite, -1 est codé avec un nombre dont tous les bits sont à 1 en complément à un/deux. : <math>S + (R << 1) = 111 \cdots 111111</math> [[File:Sum + retenue add.png|centre|vignette|upright=2|Sum + retenue add]] Un simple raisonnement nous permet de savoir si le résultat est bien -1, sans faire l'addition <math>S + (R << 1)</math>. En effet, on ne peut obtenir -1 que si la somme est l'inverse des retenues : un 0 dans le premier nombre correspond à un 1 dans l'autre, et réciproquement. En clair, on doit avoir <math>\overline{S} = R << 1</math>. Pour vérifier cela, il suffit de faire un simple XOR entre la somme et les retenues décalées d'un cran. On a alors : : <math>S \oplus (R << 1) = 111 \cdots 111111</math> La comparaison avec -1 se fait avec une porte ET à plusieurs entrées. En effet, la porte donnera un 1 seulement si tous les bits d'entrée sont à 1, ce qui est ce qu'on veut tester. Au final, l'additionneur pour l'addition adresse + décalage est remplacé par un additionneur carry-save suivi d'une couche de portes XOR et d'un comparateur avec une constante, ce qui économise de circuits et améliore les performances. [[File:Final circuit of sum addressed cache.png|centre|vignette|upright=2|Cache adressé par somme.]] En prenant en compte que la constante K est justement une constante, certaines entrées de l'additionneur carry-save sont toujours à 0 ou à 1, ce qui permet quelques simplifications à grand coup d’algèbre de Boole. Chaque additionneur complet qui compose l’additionneur carry-save est remplacée par des demi-additionneurs (ou par un circuit similaire). Autant dire que l'on gagne tout de même un petit peu en rapidité, en supprimant une couche de portes logiques. Le circuit de décodage économise aussi des portes logiques, ce qui est appréciable. ==Les caches à accès uniforme et non-uniforme== Intuitivement, le temps d'accès au cache est le même pour toutes les lignes de cache. Il s'agit de cache appelés '''caches à accès uniforme''', sous-entendu à temps d'accès uniforme. Mais sur les caches de grande capacité, il arrive souvent que le temps de propagation des signaux varie fortement suivant la ligne de cache à lire. D'ordinaire, on se cale sur la ligne de cache la plus lente pour caler la fréquence d'horloge du cache, même si on pourrait faire mieux. Cependant, les '''caches à accès non uniforme''' ont une latence différente pour chaque ligne d'un même cache. Certaines lignes de cache sont plus rapides que d'autres. Niveau terminologie, nous allons parler de caches UCA et NUCA : ''Uniform Access Cache'' pour les caches à accès uniforme, ''Non-Uniform Access Cache'' pour les caches à accès non-uniforme. [[File:Caches UCA et NUCA.png|vignette|Caches UCA et NUCA.]] Les caches NUCA et UCA sont souvent composés de plusieurs banques séparées, typiquement une par voie. Sur les caches UCA, les banques sont interconnectées avec le processeur de manière à ce que toutes les interconnexions ont la même longueur pour toutes les banques. Typiquement, les banques sont organisées en carré, avec les interconnexions qui partent du centre, avec une disposition en H, illustrée ci-contre Mais avec les caches NUCA, ce n'est pas le cas. Les interconnexions sont simplifiées et ont des longueurs différentes. Les caches NUCA n'ont pas tous le même genre d'interconnexions, qui dépendent du cache NUCA. En général, les interconnexion forme un réseau avec des sortes de routeurs qui redirigent les données/commandes vers la bonne destination : cache ou processeur. Les banques plus proches du processeur sont accessibles plus rapidement que celles éloignées, même si la différence n'est pas énorme. Les caches NUCA sont généralement associatifs par voie. Les plus simples utilisent une banque par voie pour le cache, ce qui fait que certaines voies répondent plus vite que les autres. La détection des succès de cache est alors plus rapide si la donnée lue/écrite est dans une voie/banque rapide. En théorie, les défauts de cache demandent de vérifier toutes les banques, et se calent donc sur la pire latence. Mais divers caches se débrouillent pour que ce ne soit pas le cas, soit en vérifiant les banquyes unes par une, soit par un mécanisme de recherche plus complexe. Les caches NUCA sont surtout utilisés pour les caches L3 et L4, éventuellement les caches L2. Les caches L1 sont systématiquement des caches UCA, car la latence de l'accès au cache L1 est utilisée par le processeur pour décider quand lancer les instructions. Pour simplifier, le processeur peut démarrer en avance une instruction avant qu'une opérande soit lue dans le cache L1, de manière à ce que la donnée arrive en entrée de l'ALU pile en même temps que l'instruction. Une histoire d'exécution dans le désordre et d'émission anticipée des instructions qu'on détaillera dans une bonne dizaine de chapitres. Toujours est-il que tout est plus simple pour le processeur si le cache L1 a un temps d'accès fixe. Par contre, les caches L3 et L4 sont traités en attendant que les données arrivent, le processeur reprend l'exécution des instructions quand les caches L3 et L4 ont terminé de répondre, pas avant. Avec l'association une banque = une voie, la correspondance ligne de cache → bloc de mémoire qui est statique : on ne peut pas déplacer le contenu d'une ligne de cache dans une autre portion de mémoire plus rapide suivant les besoins. Mais la recherche académique a étudié le cas où la correspondance entre une ligne de cache et une banque varie à l’exécution. Pour nommer cette distinction, on parle de caches S-NUCA (''Static NUCA'') et D-NUCA (''Dynamic NUCA''). Intuitivement, on s'attend à ce que les caches D-NUCA soient plus performants que les caches S-NUCA. Les lignes de cache les plus utilisées peuvent migrer dans une banque rapide, alors que les lignes de cache moins utilisées vont dans une banque éloignée. Les lignes de cache se répartissent dans le cache dynamiquement dans les banques où elles sont le plus adaptées. Mais paradoxalement, le gain des caches D-NUCA est presque nul, voire insignifiant. La raison est que les caches D-NUCA doivent incorporer un système pour déterminer dans quelle banque se situe la donnée pour détecter les succès/défauts de cache, ainsi qu'un système pour migrer les données entre banques. Et ce système augmente le temps d'accès au cache, réduisant à néant l'intérêt d'un cache D-NUCA. Si on économise quelques microsecondes de temps d'accès en passant d'un cache UCA à un cache S-NUCA, ce n'est pas pour les perdre en passant à un D-NUCA. La majorité des caches D-NUCA sont donc en cours de recherche, mais ne sont pas utilisés en pratique. ==La tolérance aux erreurs des caches== Une mémoire cache reste avant tout une mémoire RAM, bien que ce soit de la SRAM. Elle n'est pas parfaite et est donc sujette à des erreurs, qui peuvent inverser un bit ou l'effacer. De telles erreurs sont liées à des rayons cosmiques très énergétiques, à des particules alpha produites par le packaging ou le métal deu circuit intégré, peu importe : l'essentiel est qu'ils inversent parfois un bit. Les mémoires modernes savent se protéger contre de telles erreurs, en utilisant trois moyens. ===Les mémoires caches ECC et à bit de parité=== Le premier moyen est l'usage de codes correcteurs d'erreurs, qui ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Les bits ajoutés dépendent de la donnée mémorisée dans le byte, et servent à détecter une erreur, éventuellement à la corriger. Le cas le plus simple ajoute un simple bit de parité pour chaque byte et se contente de détecter les erreurs dans les corriger. Les autres codes ECC permettent eux de corriger des erreurs, mais ils demandent d'ajouter au moins deux bits par byte, ce qui a un cout en circuit plus élevé. Un simple bit de parité permet de détecter qu'un bit a été inversé, mais ne permet pas de corriger l'erreur. En soi, ce n'est pas un problème. Si une erreur est détectée, on considère que la ligne de cache est invalide. Le cache gère la situation comme un défaut de cache et va chercher la donnée valide en mémoire RAM. Le cout en circuits est donc faible, mais les défauts de cache sont plus nombreux. Les codes ECC sont eux capables de corriger les erreurs, si elles ne modifient pas trop de bits d'un coup. Par contre, ils utilisent deux à trois bits par octet, ce qui a un cout en circuits loin d'être négligeable. Il y a donc un compromis entre défauts de cache et cout en circuits. La gestion de l'ECC est différente suivant le niveau de cache. Généralement, le cache L1 n'utilise pas l'ECC mais se contente d'un simple bit de parité pour éviter la corruption de ses données. Le cache étant petit, les corruptions de données sont assez rares, et les défauts de cache induits faibles. Il est plus important d'utiliser un code de détection d'erreur simple, rapide, qui ne ralentit pas le cache et n'augmente pas sa latence. Si une ligne de cache est corrompue, il a juste à aller lire la ligne depuis le cache L2, ou un niveau de cache inférieur. Du moins, c'est possible sur le cache en question est un cache inclusif et/ou ''write-through''. Par contre, le niveau de cache L2 et ceux en-dessous utilisent presque systématiquement une mémoire SRAM ECC. La raison principale étant que ce sont des caches assez gros, pour lesquels la probabilité d'une erreur est assez élevée. Plus une mémoire a de bits et prend de la place, plus il y a une chance élevée qu'un bit s'inverse. Et vu que les caches L2/L3/L4 sont par nature plus lents et plus gros, ils peuvent se permettre le cout en performance lié à l'ECC, idem pour le cout en circuit. Sans compter qu'en cas d'erreur, ils doivent aller lire la ligne de cache originelle en mémoire RAM, ce qui est très lent ! Mieux vaut corriger l'erreur sur place en utilisant l'ECC. ===L'usage du ''memory scrubbing'' sur les caches=== La plupart des erreurs ne changent qu'un seul bit dans un byte, mais le problème est que ces erreurs s'accumulent. Entre deux accès à une ligne de cache, il se peut que plusieurs erreurs se soient accumulées, ce qui dépasse les capacités de correction de l'ECC. Dans ce cas, il existe une solution appelée le ''memory scrubbing'', qui permet de résoudre le problème au prix d'un certain cout en performance. Pour rappel, l'idée est de vérifier les lignes de caches régulièrement, pour éviter que les erreurs s'accumulent. Par exemple, on peut vérifier chaque ligne de cache toutes les N millisecondes, et corriger une éventuelle erreur lors de cette vérification. En faisant des vérifications régulières, on garantir que les erreurs n'ont pas le temps de s'accumuler, sauf en cas de malchance avec des erreurs très proches dans le temps. Il ne s'agit pas d'un rafraichissement mémoire, car les SRAM ne s'effacent pas), mais ça a un effet similaire. Et évidemment, le ''memory scrubbing'' a un cout en performance. On peut faire une comparaison avec le rafraichissement mémoire : les rafraichissement réguliers réduisent les performances, car cela fait des accès en plus. Des accès qui sont de plus timés à des instants bien précis qui ne sont pas forcément les plus adéquats. Il est possible qu'un rafraichissement ait lieu en même temps qu'un accès mémoire et le rafraichissement a la priorité, ce qui réduit les performances. La même chose arrive avec les vérifications du ''memory scrubbing''. Malgré tout, la technique a été utilisée sur les caches de certains processeurs commerciaux, dont des processeurs AMD Athlon et Athlon 64. Elle est surtout utilisable sur les caches L2/L3, pour lesquels le cout du pseudo-rafraichissement est acceptable. ==Un exemple de cache : le cache d'instruction== La grande majorité des processeurs utilise deux caches L1 séparés : un '''cache d'instructions''' dédié aux instructions, et un autre pour les données. Une telle organisation permet de charger une instruction tout en lisant une donnée en même temps. Notons que seul le cache L1 est ainsi séparé entre cache de données et d'instructions. Le cache d’instruction se situe en théorie entre l'unité de chargement et l'unité de décodage. En effet, ce cache prend en entrée une adresse et fournit une instruction. L'adresse est fournie par le ''program counter'', l'instruction est envoyée dans l'unité de décodage. Le cache se situe donc entre les deux. Le cache de données L1 est connecté au chemin de données, et notamment aux unités de communication avec la mémoire, pas au séquenceur. [[File:Caches L1 et positions dans le processeur.png|centre|vignette|upright=2.5|Caches L1 et positions dans le processeur]] Les deux caches sont reliés au processeur par des bus séparés, l'ensemble ressemble à une architecture Harvard, mais où les caches remplacent les mémoires RAM/ROM. Le cache d'instruction prend la place de la mémoire ROM et le cache de données prend la place de la mémoire RAM. Évidemment, il y a des niveaux de caches en dessous des caches de données/instruction, et ceux-ci contiennent à la fois données et instructions, les deux ne sont pas séparées dans des mémoires/caches séparés. Raison pour laquelle l'ensemble est appelé une '''architecture Harvard modifiée'''. Architecture Harvard, car l'accès aux données et instructions se font par des voies séparées pour le processeur, modifiée car la séparation n'est effective que pour le cache L1 et pas les autres niveaux de cache, et encore moins la RAM. Sur les processeurs modernes, il arrive très souvent que le processeur doive charger une instruction et lire/écrire une donnée en même temps. Et à vrai dire, c'est la règle plus que l'exception. L'usage d'une architecture Harvard modifiée permet cela très facilement : on peut accéder au cache d'instruction via un bus, et au cache de donnée avec l'autre ===Pourquoi scinder le cache L1 en cache d'instruction et de données=== L'usage d'un cache d’instruction séparé du cache de données est à contraster avec l'usage d'un cache L1 multiport unique, capable de mémoriser à la fois instructions et données. Les deux solutions sont possibles ont été utilisées. Les premiers processeurs avaient un cache L1 unique et multiport, mais ce n'est plus le cas sur les processeurs modernes, car les contraintes ne sont pas les mêmes. Le compromis à faire est celui entre deux petits caches rapides et un gros cache plus lent. Pour rappel, plus un cache est petit, plus il est rapide et chauffe moins. Donc au lieu d'utiliser, par exemple, un gros cache lent de 64 Kibioctets, on utilise deux caches de 32 kibioctets, plus rapides. La capacité totale est la même, mais le temps d'accès plus faible. En termes de temps d'accès, la meilleure solution est celle des deux caches simple port. Mais pour ce qui est de l'économie de circuits, c'est moins évident. Entre deux mémoires simple port et une mémoire multiport, la différence en termes de transistors est ambigüe et dépend de la capacité des caches. La différence est surtout notable pour les gros caches, moins pour les petits caches. Il faut aussi tenir compte de la capacité effective. Avec deux caches séparés, la répartition de la capacité du cache L1 est fixée une bonne fois pour toutes. Par exemple, avec un cache d'instruction de 32 KB et un cache de données de 32 KB, impossible d'allouer 40 KB aux données et 20 aux instructions. Alors qu'avec un cache L1 unique de 64 KB, on pourrait le faire sans soucis. La répartition se fait naturellement, en fonction de la politique de remplacement du cache et est proche de l'optimal. C'est là un désavantage des caches d'instructions/données séparés : une capacité effective moindre. Tout cela explique pourquoi le cache L1 est le seul à être ainsi scindé en deux, avec une séparation entre instructions et données : les contraintes au niveau du cache L1 et L2 ne sont pas les mêmes. Pour les caches L1, le temps d'accès est plus important que la capacité, ce qui favorise les caches séparés. Par contre, pour les caches L2/L3/L4, le temps d'accès n'est pas déterminant, alors que la capacité effective et l'économie en circuits sont significatives. ===La connexion des caches L1 avec le cache L2=== Pour les connexions avec le cache L2, tout dépend du processeur. Certains utilisent un cache L2 multiport, qui permet aux deux caches L1 de lire ou écrire dans le cache L2 simultanément. [[File:Cache d'instructions.png|centre|vignette|upright=1.5|Cache d'instructions.]] Si le cache L2 ne gère pas les accès simultanés, il n'y a qu'un seul bus relié aux caches L1 et au cache L2. On doit effectuer un arbitrage pour décider quel cache a la priorité, chose qui est réalisé par un circuit d'arbitrage spécialisé. [[File:Circuit d'arbitrage du cache.png|centre|vignette|upright=1.5|Circuit d'arbitrage du cache.]] Généralement, les caches d'instructions peuvent se permettre d'être plus petits que les caches de données, car les programmes sont souvent plus petits que les données manipulées. Songez que des programmes de quelques mébioctets peuvent parfois remplir la RAM avec plusieurs gibioctets de données. Lancez votre navigateur internet et ouvrez une page web un peu chargée, pour vous en convaincre ! ===Les spécificités du cache d'instruction : lecture seule, bloquant, etc=== Les instructions sont rarement modifiées ou accédées en écritures, contrairement aux données. Et cela permet d'utiliser un cache simplifié pour les instructions. Autant un cache généraliste doit permettre les lectures et écritures depuis le processeur (avec les échanges avec la RAM), autant un cache d'instruction peut se contenter des lectures provenant du CPU et des échanges avec la RAM. Le cache d'instructions est donc très souvent en « lecture seule » : le processeur ne peut pas écrire dedans, mais juste le lire ou charger des instructions dedans. Un cache d'instruction est donc plus simple qu'un cache pour les données : on peut retirer les circuits en charge de l'écriture (mais on doit laisser un port d'écriture pour charger les instructions dedans). Le gain en circuits permet d'utiliser un cache d'instruction plus gros ou au contraire de laisser de la place pour le cache de données. Le gain en termes de capacité compense alors un peu les inconvénients des caches séparés. Par contre, cela complique la gestion du code automodifiant, c'est-à-dire des programmes dont certaines instructions vont aller en modifier d'autres, ce qui sert pour faire de l'optimisation ou est utilisé pour compresser ou cacher un programme (les virus informatiques utilisent beaucoup de genre de procédés). Quand le processeur exécute ce genre de code, il ne peut pas écrire dans ce cache L1 d'instructions, mais doit écrire dans le cache L2 ou en RAM, avant de recharger les instructions modifiées dans le cache L1. Cela qui prend du temps et peut parfois donner lieu à des erreurs si le cache L1 n'est pas mis à jour. Les algorithmes de remplacement des lignes de cache optimaux pour les données ne le sont pas pour les instructions, de même que la taille optimale du cache, la taille des lignes de cache optimale, ou même les algorithmes de préchargement. Par exemple, pour le remplacement des lignes de cache, un simple algorithme LRU est presque optimal pour les instructions, autant il peut donner de mauvaises performances quand on manipule beaucoup de tableaux. Cela justifie d'utiliser des caches spécialisés pour chacune. On peut adapter le cache d'instruction à son contenu, ce qui le rend plus rapide ou plus petit à performance égale. Les caches d'instructions sont généralement des caches bloquants. Il ne servirait à rien de rendre un cache d'instruction non-bloquant, le cout en circuits ne se traduirait pas par une augmentation significative des performances. À l'opposé, les caches de données sont non-bloquants sur les architectures modernes, pour des raisons de performance. Ce qui rend la séparation assez intéressante, les deux caches ayant des besoins différents et des implémentations différentes, cela permet d'optimiser le cout en transistors des caches. ===L'impact du cache d'instruction sur les performances=== Sur les architectures conventionnelles, le cache d'instruction a plus d'impact sur les performances que le cache de données. La raison principale est que les instructions ont une meilleure localité spatiale et temporelle que pour les données. Pour la localité spatiale, les instructions consécutives se suivent en mémoire, alors que rien ne garantit que des données utilisées ensemble soient regroupées en mémoire. Pour localité temporelle, elle est très variable pour les données, mais très courante pour les instructions du fait de l'usage fréquent des boucles et des fonctions. : La présence de branchements atténue la localité temporelle des instruction, sauf que la majorité des branchements sautent à un endroit très proche, seuls les appels de fonction brisent la localité spatiale. La conséquence est qu'il arrive que certains CPU aient un cache L1 d'instruction plus gros que celui pour les données. On parle alors de '''cache L1 asymétriques'''. Un exemple est celui des processeurs AMD de microarchitecture Zen, dont le cache d'instruction était deux fois plus gros que le cache de données. Leur cache d'instruction faisait 64 kibioctets, contre seulement 32 pour le cache de données. D'ailleurs, il existe des processeurs assez extrêmes qui se contentent d'un cache d'instruction unique, sans cache de données. C'est le cas sur les processeurs vectoriels ou les GPU que nous verrons dans les chapitres de fin de ce wikilivres. De tels processeurs sont spécialisés dans la manipulation de tableaux de données, traitement qui a une faible localité temporelle. En conséquence, utiliser un cache de données n'est pas vraiment utile, voire peu être contreproductif, alors qu'un cache d’instruction fonctionne parfaitement. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Compléments sur les mémoires de masse | prevText=Compléments sur les mémoires de masse | next=Le préchargement | nextText=Le préchargement }} </noinclude> p2fr2vtz4td7y11e41w2e7b995b98c8 765127 765126 2026-04-26T16:36:22Z Mewtow 31375 /* Le contrôleur de cache 82385 pour les CPU Intel 386 */ 765127 wikitext text/x-wiki Le cache est une mémoire intercalée entre la mémoire et un processeur, plus rarement à l'intérieur d'un périphérique. Il est souvent fabriquée avec de la mémoire SRAM, parfois avec de l'eDRAM. Sans lui, on se croirait à l'âge de pierre tellement nos PC seraient lents ! En effet, la mémoire est très lente comparée au processeur. Le temps mis pour accéder à la mémoire est du temps durant lequel le processeur n'exécute pas d'instruction (sauf cas particuliers impliquant un pipeline). Pour diminuer ce temps d'attente, il a été décidé d'intercaler une mémoire petite mais rapide, entre le processeur et la mémoire. Ainsi, le processeur accède à un cache très rapide plutôt qu'à une RAM beaucoup plus lente. ==L'accès au cache== Le cache contient une copie de certaines données présentes en RAM. La copie présente dans le cache est accessible bien plus rapidement que celle en RAM, vu que le cache est plus rapide. Mais seule une petite partie de ces données sont copiées dans le cache, les autres données devant être lues ou écrites dans la RAM. Toujours est-il que le cache contient une copie des dernières données accédées par le processeur. Une donnée est copiée dans la mémoire cache quand elle est lue ou écrite par le processeur. Le processeur conserve une copie de la donnée dans le cache après son premier accès. Les lectures/écritures suivantes se feront alors directement dans le cache. Évidemment, au fur et à mesure des accès, certaines données anciennes sont éliminées du cache pour faire de la place aux nouveaux entrants, comme nous le verrons plus tard. [[File:Principe d'une mémoire cache.gif|centre|vignette|upright=2|Principe d'une mémoire cache.]] La mémoire cache est invisible pour le programmeur, qui ne peut pas déceler celles-ci dans l'assembleur. Les accès mémoire se font de la même manière avec ou sans le cache. La raison à cela est que le cache intercepte les accès mémoire et y répond s'il en a la capacité. Par exemple, si le cache intercepte une lecture à une adresse et que le contenu de cette adresse est dans le cache, le cache va outrepasser la mémoire RAM et la donnée sera envoyée par le cache au lieu d'être lue en RAM. par contre, si un accès se fait à une adresse pour laquelle le cache n'a pas la donnée, alors l'accès mémoire sera effectué par la RAM de la même manière que si le cache n'était pas là. [[File:Accès au cache.png|centre|vignette|upright=2|Accès au cache]] ===Les succès et défauts de caches=== Tout accès mémoire est intercepté par le cache, qui vérifie si la donnée demandée est présente ou non dans le cache. Si la donnée voulue est présente dans le cache, on a un '''succès de cache''' (''cache hit'') et on accède à la donnée depuis le cache. Sinon, c'est un '''défaut de cache''' (''cache miss'') et on est obligé d’accéder à la RAM. Les défauts de cache peuvent avoir plusieurs origines. Tout ce qu'il faut savoir est que lorsque le processeur accède à une donnée ou une instruction pour la première fois, il la place dans la mémoire cache car elle a de bonnes chances d'être réutilisée prochainement. La raison à cela est qu'un programme a tendance à réutiliser les instructions et données qui ont été accédées dans le passé : c'est le ''principe de localité temporelle''. Bien évidement, cela dépend du programme, de la façon dont celui-ci est programmé et accède à ses données et du traitement qu'il fait, mais c'est souvent vrai en général. La première cause des défauts de cache est liée à la taille du cache. À force de charger des données/instructions dans le cache, le cache fini par être trop petit pour conserver les anciennes données. Le cache doit bien finir par faire de la place en supprimant les anciennes données, qui ont peu de chances d'être réutilisées. Ces anciennes données éliminées du cache peuvent cependant être accédées plus tard. Tout prochain accès à cette donnée mènera à un cache miss. C'est ce qu'on appelle un ''Capacity Cache Miss'', ou encore '''défaut de capacité'''. Les seules solutions pour éviter cela consistent à augmenter la taille du cache ou à optimiser le programme exécuté (voir plus bas). Une autre raison pour un défaut est donc la suivante. Lorsqu'on exécute à une instruction ou qu'on accède à donnée pour la première fois, celle-ci n'a pas encore été chargée dans le cache. Le défaut de cache est inévitable : ce genre de cache miss s'appelle un ''Cold Miss'', ou encore un '''défaut à froid'''. De tels défauts sont presque impossibles à éliminer, sauf à utiliser des techniques de préchargement qui chargent à l'avance des données potentiellement utiles. Ces méthodes de préchargement se basent sur le principe de localité spatiale, à savoir le fait que les programmes ont tendance à accéder à des données proches en mémoire. Pour donner un exemple, les instructions d'un programme sont placées en mémoire dans l’ordre dans lequel on les exécute : la prochaine instruction à exécuter est souvent placée juste après l'instruction en cours (sauf avec les branchements). Quand on accède à une donnée ou une instruction, le cache peut précharger les données adjacentes pour en profiter. Nous parlerons de ces techniques de préchargement dans un chapitre dédié, vers la fin du cours. ===Le fonctionnement du cache, vu du processeur=== Vu du processeur, le cache prend en entrée toutes les informations nécessaires pour effectuer un accès mémoire : des signaux de commande, une adresse et la donnée à écrire si besoin. Tout cela est passé en entrée du cache, celui-ci répondant aux accès mémoire via divers bits de contrôles, que le processeur peut lire à souhait. Le cache fournit aussi la donnée à lire, pour les lectures, sur une sortie, connectée directement au bus mémoire/processeur. Globalement, le cache a une capacité limitée, mais il prend en entrée des adresses complètes. Par exemple, sur un processeur 64 bits, le cache prend en entrée des adresses de 64 bits (sauf si optimisations), même si le cache en question ne fait que quelques mébioctets. Les caches sont souvent des mémoires multiports, surtout sur les processeurs récents. Les caches simple port sont rares, mêmes s'ils existent et ont existé par le passé. les caches double port sont eux plus fréquents, et ont généralement un port d'écriture séparé du port de lecture. Mais les caches récents ont plusieurs ports de lecture/écriture et sont capables de gérer plusieurs accès mémoire simultanés. Les données présentes dans le cache sont (pré)chargées depuis la mémoire, ce qui fait que toute donnée dans le cache est la copie d'une donnée en mémoire RAM. Le cache doit faire la correspondance entre une donnée du cache et l'adresse mémoire correspondante. Du point de vue du fonctionnement, on peut voir le cache comme une sorte de table de correspondance, qui mémorise des données, chacune étant associée à son adresse mémoire. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Cela vaut du point de vue du processeur, le fonctionnement interne du cache étant quelque peu différent selon le cache. Il existe des caches dont le fonctionnement interne est bien celui d'une table de correspondance matérielle, d'autres qui sont beaucoup plus optimisés. [[File:Fonctionnement d'une mémoire associative à correspondance.png|centre|vignette|upright=2|Fonctionnement simplifié d'une mémoire cache : les adresses sont dans la colonne de gauche, les données sont dans la colonne de droite. On voit qu'on envoie l'adresse au cache, que celui-ci répond en renvoyant la donnée associée.]] ==La performance des mémoires caches== L'analyse de la performance des mémoires caches est plus riche pour celle des autres mémoires. Sa performance dépend de beaucoup de paramètres, mais on peut cependant citer les principaux. Les deux premiers sont tout bonnement sa latence et son débit, comme pour n'importe quelle autre mémoire. La latence est plus importante que son débit, car le processeur est généralement plus rapide que le cache et qu'il n'aime pas attendre. Mais le critère le plus important pour un cache est sa capacité à empêcher des accès mémoire, son efficacité. Plus les accès mémoire sont servis par le cache au lieu de la RAM, meilleures seront les performances. Pour résumer, la performance d'un cache est surtout caractérisée par deux métriques : le taux de défaut, qui correspond à l’efficacité du cache, et la latence du cache. ===Le taux de succès/défaut=== Le '''taux de succès''' (hit ratio) est un premier indicateur des performances du cache, mais un indicateur assez imparfait. C'est le pourcentage d'accès mémoire qui ne déclenchent pas de défaut de cache. Plus il est élevé, plus le processeur accède au cache à la place de la RAM et plus le cache est efficace. Certains chercheurs préfèrent utiliser le '''taux de défauts''', à savoir le pourcentage d'accès mémoire qui entraînent un défaut de cache. Plus il est bas, meilleures sont les performances. Le taux de défaut est relié au taux de succès par l'équation <math>T_\text{succes} = 1 - T_\text{defaut}</math>. Par définition, il est égal à : : <math>\text{Taux de défauts de cache} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d’accès mémoires}}</math> Plutôt que de comparer le nombre de défauts/succès de cache au nombre d'accès mémoire, il est aussi possible de diviser le nombre de défauts par le nombre total d'instructions. On obtient alors le '''taux de défauts/succès par instruction''', une autre métrique utile. Par définition, elle est égale à : : <math>\text{Taux de défauts par instruction} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d'instructions}} = \text{Taux de défauts de cache} \times \frac{\text{Nombre d’accès mémoires}}{\text{Nombre d'instructions}}</math> Si certains défauts de cache sont inévitables quel que soit le cache, comme les défauts à froids, mentionnés plus haut, d'autres défauts peuvent être évités en augmentant la capacité du cache. C'est le cas des défauts de capacité qui sont causés par un accès à une donnée qui a été éliminée du cache faute de place. Plus le cache est gros, moins il a de chances d'être rempli, moins il doit rapatrier de données, plus son taux de succès augmente. Mais nous reviendrons sur le lien entre taille du cache et taux de défaut plus bas. Le taux de succès ne dépend pas que du cache, mais aussi de la conception des programmes exécutés. Une bonne utilisation du cache (ainsi que de la mémoire virtuelle) repose sur le programmeur qui doit prendre en compte les principes de localités dès la conception de ses programmes. Par exemple, un programmeur peut parfaitement tenir compte du cache au niveau de son algorithme : on peut citer l'existence des algorithmes ''cache oblivious'', qui sont conçus pour être optimaux quelle que soit la taille du cache. Le programmeur peut aussi choisir ses structures de données de manière à améliorer la localité. Par exemple, un tableau est une structure de donnée respectant le principe de localité spatiale, tandis qu'une liste chaînée ou un arbre n'en sont pas (bien qu'on puisse les implémenter de façon à limiter la casse). D'autres optimisations sont parfois possibles : par exemple, le sens de parcours d'un tableau multidimensionnel peut faire une grosse différence. Cela permet des gains très intéressants pouvant se mesurer avec des nombres à deux ou trois chiffres. Je vous recommande, si vous êtes programmeur, de vous renseigner le plus possible sur les optimisations de code ou algorithmiques qui concernent le cache : il vous suffira de chercher sur Google. Il y a une citation qui résume bien cela, prononcée par un certain Terje Mathisen. Si vous ne le connaissez pas, cet homme est un vieux programmeur (du temps durant lequel on codait encore en assembleur), grand gourou de l’optimisation, qui a notamment travaillé sur le moteur de Quake 3 Arena. {{BlocCitation|Almost all programming can be viewed as an exercise in caching.|auteur=Terje Mathisen}} ===La latence moyenne d'un cache=== Le temps mis pour lire ou écrire une donnée varie en présence d'un cache. Certaines lectures/écritures vont atterrir directement dans le cache (succès) tandis que d'autres devront aller chercher leur contenu en mémoire RAM (défaut de cache). Dans tous les cas, qu'il y ait défaut ou non, le cache sera consulté et mettra un certain temps à répondre, égal au temps de latence du cache. Tous les accès mémoires auront donc une durée au moins égale au temps de latence du cache, qui sera notée <math>T_c</math>. En cas de succès, le cache aura effectué la lecture ou l'écriture, et aucune action supplémentaire n'est requise. Ce qui n'est pas le cas en cas de défaut : le processeur devra aller lire/écrire la donnée en RAM, ce qui prend un temps supplémentaire égal au temps de latence de la mémoire RAM. Un défaut ajoute donc un temps, une pénalité, à l'accès mémoire. Dans ce qui suivra, le temps d'accès à la RAM sera noté <math>T_m</math>. Fort de ces informations, nous pouvons calculer le temps de latence moyen d'un accès mémoire, qui est la somme du temps d'accès au cache (pour tous les accès mémoire), multiplié par le temps lié aux défauts. On a alors : : <math>T = T_c + \text{Taux de défaut} \times T_m</math> On voit que plus le taux de succès est élevé, plus le temps de latence moyen sera bas, et inversement. Ce qui explique l'influence du taux de succès sur les performances du cache, influence assez importante sur les processeurs actuels. De nos jours, le temps que passe le processeur dans les défauts de cache devient de plus en plus un problème au fil du temps, et gérer correctement le cache est une nécessité, particulièrement sur les processeurs multi-cœurs. Il faut dire que la différence de vitesse entre processeur et mémoire est tellement importante que les défauts de cache sont très lents : alors qu'un succès de cache va prendre entre 1 et 5 cycles d'horloge, un cache miss fera plus dans les 400-1000 cycles d'horloge. Tout ce temps sera du temps de perdu que le processeur aura du mal à mitiger. Autant dire que réduire les défauts de cache est beaucoup plus efficace que d'optimiser les calculs effectués par le processeur (erreur courante chez de nombreux programmeurs, notamment débutants). ===L'impact de la taille du cache sur le taux de défaut et la latence=== Il y a un lien entre taille du cache, taux de défaut, débit binaire et latence moyenne. Globalement, plus un cache est gros, plus il est lent. Simple application de la notion de hiérarchie mémoire vue il y a quelques chapitres. Les raisons à cela sont nombreuses, mais nous ne pouvons pas les aborder ici, car il faudrait que nous sachions comment fonctionne un cache et ce qu'il y a à l'intérieur, ce qui sera vu dans la suite du chapitre. Toujours est-il que la latence moyenne d'un cache assez gros est assez importante. De même, le débit binaire d'un cache diminue avec sa taille, mais dans une moindre mesure. Les petits caches ont donc un gros débit binaire et une faible latence, alors que c'est l'inverse pour les gros caches. Une grande capacité de cache améliore le taux de succès, mais cela se fait au détriment de son temps de latence et de son débit, ce qui fait qu'il y a un compromis assez difficile à trouver entre taille du cache, latence et débit. Il peut arriver qu'augmenter la taille du cache augmente son temps d'accès au point d’entraîner une baisse de performance. Par exemple, les processeurs Nehalem d'Intel ont vus leurs performances dans certains jeux vidéos baisser de 2 à 3 %, malgré de nombreuses améliorations architecturales, parce que la latence du cache L1 avait augmentée de 2 cycles d'horloge. Pour avoir une petite idée du compromis à faire, regardons la relation entre taille du cache et taux de défaut. Il existe une relation approximative entre ces deux variables, appelée la '''loi de puissance des défauts de cache'''. Elle donne le nombre total de défaut de cache en fonction de la taille du cache et de deux autres paramètres. Voici cette loi : : <math>\text{Taux de défauts de cache} \approx K \times \text{Taille du cache}^{- \alpha }</math>, avec <math>K</math> et <math>\alpha</math> deux coefficients qui dépendent du programme exécuté. Le coefficient <math>\alpha</math> est généralement compris entre 0.3 et 0.7, guère plus, et varie suivant le programme exécuté. Précisons que cette loi ne marche que si le cache est assez petit par rapport aux données à utiliser. Pour un cache assez gros et des données très petites, la relation précédente est mise en défaut. Pour s'en rendre compte, il suffit d'étudier le cas extrême où toutes les données nécessaires tiennent dans le cache. Dans ce cas, il n'y a qu'un nombre fixe de défauts de cache : autant qu'il faut charger de données dans le cache. Le nombre de défauts de cache observé dans cette situation n'est autre que le coefficient <math>K</math> de la situation précédente, mais il n'y a aucune dépendance entre taux de défaut et taille du cache. L'origine de cette relation s'explique quand on regarde combien de fois chaque donnée est réutilisée lors de l’exécution d'un programme. La plupart des données finissent par être ré-accédées à un moment ou un autre et il se passe un certain temps entre deux accès à une même donnée. Sur la plupart des programmes, les observations montrent que beaucoup de réutilisations de données se font après un temps très court et qu'inversement, peu de ré-accès se font après un temps inter-accès long. Si on compte le nombre de réutilisation qui ont un temps inter-accès bien précis, on retrouve une loi de puissance identique à celle vue précédemment : : <math>\text{Nombre de réaccès avec un temps inter-accès égal à t} \approx K \times t^{- \beta}</math>, avec t le temps moyen entre deux réutilisations. Le coefficient <math>\beta</math> est ici compris entre 1.7 et 1.3. De manière générale, les coefficients <math>\alpha</math> et <math>\beta</math> sont reliés par la relation <math>\alpha = 1 - \beta</math>, ce qui montre qu'il y a un lien entre les deux relations. Précisons cependant que la loi de puissance précédente ne vaut pas pour tous les programmes informatiques, mais seulement pour la plupart d’entre eux. Il n'est pas rare de trouver quelques programmes pour lesquels les accès aux données sont relativement prédictibles et où une bonne optimisation du code fait que la loi de puissance précédente n'est pas valide. La loi de puissance des défauts de cache peut se démontrer à partir de la relation précédente, sous certaines hypothèses. Si un suppose que le cache est assez petit par rapport aux données, alors les deux relations sont équivalentes. L'idée qui se cache derrière la démonstration est que si le temps entre deux accès à une donnée est trop long, alors la donnée accédée aura plus de chance d'être rapatriée en RAM, ce qui cause un défaut de cache. La chance de rapatriement dépend de la taille du cache, un cache plus gros peut conserver plus de données et a donc un temps avant rapatriement plus long. ==Les lignes de cache et leurs tags== Du point de vue du processeur, les lectures et écritures se font mot mémoire par mot mémoire. Un processeur avec des entiers de 64 bits recoit des données de 64 bits de la part du cache, et y écrit des mots de 64 bits. Mais quand on regarde comment sont stockées les données à l'intérieur du cache, les choses sont différentes. ===Les lignes de cache=== Les données sont mémorisées dans le cache par blocs de plusieurs bytes, d'environ 64 à 256 octets chacun, qui portent le nom de '''lignes de cache'''. Les lignes de cache sont l'unité de stockage que l'on trouve à l'intérieur du cache, mais elles servent aussi d'unité de transaction avec la mémoire RAM. Sur les caches actuels, on transfère les données entre le cache et la RAM ligne de cache par ligne de cache, dans la limite de la taille du bus mémoire. Mais d'autres caches plus anciens permettaient de faire des transferts plus fins. C’est-à-dire qu'on pouvait mettre à jour quelques octets dans une ligne de cache sans avoir à la recopier intégralement depuis ou dans la mémoire RAM. En théorie, on pourrait imaginer des caches où les données sont stockées différemment, où l'unité serait le mot mémoire, par exemple. Par exemple, sur un processeur 64 bits, on aurait une ligne de cache de 64 bits. Cela aurait l'avantage de la simplicité : les transferts entre le processeur et la mémoire serait de même taille, l'intérieur du cache ressemblerait à son interface montrée au processeur. Mais cela aurait quelques défauts qui sont compensés par l'organisation en lignes de cache de grande taille. Le premier avantage des lignes de cache est lié à la localité spatiale, la tendance qu'on les programmes à accéder à des données proches les unes des autres. Des accès mémoires consécutifs ont tendance à se faire à des adresses proches, qui ont de bonnes chances d'être dans la même ligne de cache. Et des accès consécutifs à une même ligne de cache sont plus rapides que des accès à deux lignes distinctes. Une autre raison est tout simplement que cela simplifie considérablement la circuiterie du cache. Pour une capacité identique, il vaut mieux avoir peu de lignes de cache assez grosses, que beaucoup de petites lignes de cache. La raison est que les circuits du cache, comme le décodeur, l'encodeur et autres, ont moins de sorties et sont donc plus simples. ===L'alignement des lignes de cache=== Les lignes de cache sont des blocs de plusieurs dizaines à centaines de bytes, dont la taille est presque toujours une puissance de deux. De plus, les lignes de cache sont alignées en mémoire. Nous avions déjà abordé la notion d'alignement mémoire dans un chapitre précédent, mais le concept d'alignement des lignes de cache est quelque peu différent. Quand nous avions parlé d'alignement auparavant, il s'agissait de l'alignement des données manipulées par le processeur, qui faisait partie du jeu d'instruction du processeur. Ici, nous parlons d'un alignement totalement différent, invisible pour le programmeur, sans lien avec le jeu d’instruction. Voyons de quoi il retourne. Concrètement, cela veut dire que du point de vue du cache, la RAM est découpée en blocs qui font la même taille qu'une ligne de cache, aux positions prédéterminées, sans recouvrement entre les blocs. Par exemple, pour un cache dont les lignes de cache font 256 octets, le premier bloc est à l'adresse 0, le second est 256 octets plus loin, c'est à dire à l'adresse 256, le troisième à l'adresse 512, la quatrième à l'adresse 768, etc. Une ligne de cache de 256 octets contiendra une donnée provenant d'un bloc de RAM de 256 octets, dont l'adresse est systématiquement un multiple de 256. Il n'est pas possible qu'une ligne de cache contienne un bloc de 256 octets dont l'adresse du premier octet serait l'adresse 64, ou l'adresse 32, par exemple. En clair, les adresses de ces blocs sont des multiples de la taille de la ligne de cache, de la taille des blocs. Cela rappelle les contraintes d'alignement vues dans le chapitre "Le modèle mémoire : alignement et boutisme", mais appliquées aux lignes de cache. L'alignement des lignes de cache a des conséquences pratiques pour la conception des caches. Notons qu'il est en théorie possible d'avoir des caches dont les lignes de cache ne sont pas alignées, mais cela poserait des problèmes majeurs. Il serait en effet possible qu'une donnée soit présente dans deux lignes de cache à la fois. Par exemple, prenons le cas où une ligne de cache de 256 commence à l'adresse 64 et une autre ligne de cache commence à l'adresse 0. L'adresse 128 serait dans les deux lignes de cache ! Et cela poserait des problèmes lors des lectures, mais encore plus lors des écritures. C'est pour éviter ce genre de problèmes que les lignes de cache sont alignées avec la mémoire RAM dans tous les caches existants. L'alignement des lignes de cache est une chose que les programmeurs doivent parfois prendre en compte quand ils écrivent du code ultra-optimisé, destiné à des programmes demandant des performances extrêmes. Il arrive que les contraintes d'alignement posent des problèmes. Nous avions vu dans le chapitre sur le boutisme et l'alignement qu'il valait mieux gérer l'alignement des variables des structures de données, pour éviter les accès non-alignés avec le bus mémoire. La même chose est possible, mais pour l'alignement avec des lignes de cache. Typiquement, l'idéal est que, pour une structure de donnée, on puisse en mettre un nombre entier dans une ligne de cache. Ou alors, si la structure est vraiment grande, que celle-ci occupe un nombre entier de lignes de cache. Si ce n'est pas le cas, il y a un risque d'accès non-alignés, c'est à dire qu'une structure se retrouve à cheval sur deux lignes de cache, avec les défauts que cela implique. ===Le tag d'une ligne de cache=== Plus haut, nous avions dit que le cache mémorise, pour chaque ligne de cache, l'adresse RAM associée. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Mais du fait de l'organisation du cache en lignes de cache de grande taille, qui sont de plus alignées en mémoire, il faut nuancer cette affirmation. Le cache ne mémorise pas la totalité de l'adresse, ce qui serait inutile. L'alignement des lignes de cache en RAM fait que les bits de poids faible de l'adresse ne sont pas à prendre en compte pour l'association adresse-ligne de cache. Dans ces conditions, on mémorise seulement la partie utile de l'adresse mémoire correspondante, qui forme ce qu'on appelle le '''tag'''. Le reste de l'adresse indique quelle est la position de la donnée dans la ligne de cache. Par exemple, prenons le cas où le processeur gère des nombres entiers de 64 bits (8 octets) et des lignes de cache de 128 octets : chaque ligne de cache contient donc 16 entiers. Si le processeur veut lire ou écrire un entier bien précis, il doit préciser sa place dans la ligne de cache. Et ce sont les bits de l'adresse mémoire non-inclus dans le cache qui permettent de faire ça. En clair, une adresse mémoire à lire/écrire est interprété par le cache comme la concaténation d'un tag et de la position de la donnée dans la ligne de cache correspondante. [[File:Adressage d'un cache totalement associatif.png|centre|vignette|upright=2|Adressage d'un cache totalement associatif]] Le cache est donc une grande table de correspondance entre tags et lignes de cache. Lors d'un accès mémoire, le cache extrait le tag de l'adresse à lire ou écrire, et le compare avec les tags de chaque ligne de cache. Si une ligne contient ce tag, alors c'est que cette ligne correspond à l'adresse, et c'est un défaut de cache sinon. Lors d'un succès de cache, la ligne de cache est lue depuis le cache et envoyée à un multiplexeur qui sélectionne la donnée à lire dans la ligne de cache. Le fonctionnement est similaire pour une écriture : la donnée à écrire passe dans un démultiplexeur, qui envoie la donnée au bon endroit dans la ligne de cache sélectionnée. [[File:Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.png|centre|vignette|upright=2|Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.]] ===Le contenu d'une ligne de cache=== Dans ce qui va suivre, nous allons considérer que chaque ligne de cache mémorise son tag, les données de la ligne de cache proprement dit, et quelques bits de contrôle annexes qui varient suivant le cache considéré. [[File:Tag d'une ligne de cache.png|centre|vignette|upright=2|Tag d'une ligne de cache.]] Les caches modernes incluent de nombreux bits de contrôle, mais deux d'entre eux sont communs à presque tous les caches modernes : le bit ''Dirty'' et le bit ''Valid''. Le '''bit ''Valid''''' indique si la ligne de cache contient des données valides ou non. Si le bit ''Valid'' est à 0, la ligne de cache est en état valide, à savoir qu'elle contient des données et n'est pas vide. Par contre, si ce bit est à 1, la ligne de cache est invalide et son contenu ne peut pas être lu ou écrit. L'utilité de ce bit est qu'il permet d'effacer une ligne de cache très rapidement : il suffit de mettre ce bit à 0. Il existe des situations où le cache doit être effacé, on dit alors qu'il est invalidé. Une section de ce chapitre sera dédié à l'invalidation du cache. Le '''bit ''Dirty''''' indique qu'une ligne de cache a été modifiée. Par modifiée, on veut dire que le processeur a écrit dedans, qu'il a modifié la ligne de cache. Mais attention : si la donnée a été modifiée dans le cache, la modification n'est pas forcément propagée en mémoire RAM. Le bit ''dirty'' indique si c'est le cas, si l'écriture a été propagée en mémoire RAM. Il précise que la ligne de cache contient des données modifiées, alors que la RAM a des données initiales non-modifiées. Une ligne de cache avec un bit ''dirty'' à 1 est dite ''dirty'', par métonymie. Nous verrons cela en détail dans la section sur les caches ''write-back'' et ''write-through''. Les caches modernes ajoutent des '''bits de détection/correction d'erreur''' dans les bits de contrôle. Pour rappel, les codes de détection/correction d'erreur permettent de se prémunir contre des erreurs matérielles, qui corrompent les données stockées dans une mémoire, ici une mémoire cache. Ils ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Nous reviendrons dessus dans une section ultérieur de ce chapitre. Sur certains caches assez anciens, on pouvait transférer les lignes de caches morceaux par morceaux. Ces caches avaient des lignes de cache divisées en sous-secteurs, ces sous-secteurs étant des morceaux de ligne de cache qu'on pouvait charger indépendamment les uns des autres (mais qui sont consécutifs en RAM). Chaque secteur avait ses propres bits de contrôle, mais le tag était commun à tous les secteurs. [[File:Cache à secteurs.png|centre|vignette|upright=2.5|Cache à secteurs.]] : Dans ce qui va suivre, le terme "ligne de cache" désignera soit un bloc de données copiées depuis la RAM d'une taille de 64/128/256/... octets, soit la concaténation de ces données avec le tag et des bits de contrôle. Les deux définitions ne sont pas équivalentes, mais l'usage a entériné cet abus de langage. Et il faut avouer que cela rend les explications du chapitre plus simples. ==Les instructions de contrôle du cache== Plus haut, nous avions dit que le cache est totalement transparent du point de vue du programmeur. Le cache contient des copies de données en RAM, le programmeur n'a rien à faire pour utiliser le cache correctement. Mais la réalité est que pour des raisons diverses, des processeurs incorporent des '''instructions de contrôle du cache'''. Il s'agit d’instructions qui agissent sur le contenu du cache. Elles existent pour des raisons diverses qu'on détaillera plus bas, mais il s'agit globalement d'une question de performances ou de nécessité pour le système d'exploitation. ===Les instructions de préchargement=== La première instruction de contrôle du cache est une '''instruction de préchargement''', qui demande à charger un bloc de données dans le cache. Elle prend en opérande une adresse mémoire, et le contenu de cette adresse est chargé dans une ligne de cache. Bien sûr, des contraintes d'alignement sont à prendre en compte : on charge un bloc de la même taille qu'une ligne de cache, aligné en mémoire sur la taille du bloc, qui contient l'adresse. L'instruction de préchargement n'est utile que si l'instruction est exécutée bien avant que la donnée ne soit utilisée/lue/écrite. Cela permet de charger une donnée dans le cache à l'avance, d'où le nom de préchargement donné à cette technique. Mais les processeurs modernes gérent des techniques de préchargement automatique, qui ne requièrent pas d'instructions de préchargement. Le préchargement automatique et les instructions de préchargement sont deux solutions complémentaires, mais qui peuvent se marcher sur les pieds. Nous en reparlerons dans le prochain chapitre, qui sera dédié au préchargement automatique. Il faut noter que les instructions de préchargement peuvent être ignorées par le processeur. Sous certaines conditions, le processeur peut décider que l'instruction de préchargement ne sera pas exécutée. Par exemple, il ne va pas précharger une donnée déjà présente dans le cache. Ou encore, si le bus mémoire est occupé, il ne va pas exécuter le préchargement, par manque de ressources matérielles. ===Les instructions d'invalidation et de ''flush''=== Les instructions ''flush'' regroupent deux types d'instructions qui sont souvent utilisées en même temps. Il s'agit des instructions d'invalidation et de nettoyage (''clean''). Les deux termes proviennent de la terminologie ARM, il n'y a pas de terminologie standardisé pour les noms de ces instructions. Dans les grandes lignes, elles permettent de vider le cache, à savoir de rapatrier son contenu en RAM et de réinitialiser le cache à zéro. Elles sont utilisées par le système d'exploitation lors des commutations de contexte, à savoir quand on passe d'un programme à un autre. Elles sont aussi utilisées lors des appels systèmes et routines d'interruption/exception. L'idée est de vider le cache avant d'exécuter un nouveau programme ou une nouvelle routine. Le nouveau programme aura accès à un cache tout propre, les données de l'ancien programme auront été retirée du cache. Les '''instructions ''clean''''' recopient le contenu de la ligne de cache en RAM. Elles forcent la recopie immédiatement de la ligne de cache en mémoire RAM. Pour faire leur travail, elle vérifient si la ligne de cache a été modifiée, avant de la recopier en RAM. Et pour cela, ils vérifient le bit de contrôle ''dirty'', qui est mis à 1 après une première écriture. Si ce bit est à 0, alors pas besoin de recopier la ligne de cache : elle n'a pas été modifiée, la RAM a déjà la bonne copie. Mais s'il est à 1, le cache et la RAM n'ont pas le même contenu, la recopie s'exécute. Les '''instructions d'invalidation''' permettent d'invalider une ligne de cache, à savoir d'effacer son contenu. Nous verrons à quoi servent ces instructions dans la section sur les changement de processus. Invalider une ligne de cache est une opération optimisée : le cache n'est en réalité pas réellement effacé. À la place, le bit ''Valid'' de chaque ligne de cache est juste mis à 0. Il faut noter que l'invalidation efface les lignes de cache sans se préoccuper de leur contenu. Elle se moque qu'une ligne de cache contienne une donnée modifiée, ''dirty'' ou quoique ce soit : la ligne de cache est effacée, point. Il est possible d'invalider une ligne de cache en fournissant une adresse mémoire, mais il est aussi possible d'invalider le cache tout entier. Le choix entre les deux dépend du mode d'adressage de l'instruction d'invalidation. Parfois, il existe une instruction séparée pour invalider tout le cache, et une autre pour invalider une ligne de cache bien précise. Des instructions séparées sont parfois disponibles pour invalider les caches de données et d'instructions, parfois aussi la TLB (un cache qu'on verra dans quelques chapitres). Il est possible de n'invalider que le cache L1, voire le cache L2. Il faut noter que l'invalidation efface tout le cache, mais ne se préoccupe pas de vérifier si les données ont été modifiées dans le cache. Pour certains caches, comme le cache d'instruction, ce n'est pas un problème, vu qu'il est en "lecture seule". Mais pour les caches de données, les données modifiées sont perdues en cas d'invalidation. Heureusement, il existe des instructions d'invalidation qui fusionnent une instruction ''clean'' et une instruction d'invalidation. Il s'agit d''''instructions d'invalidation spéciales'''. ===Les instructions d'optimisation : instructions non-temporelles et écritures optimisées=== Les '''instructions mémoire non-temporelles''' contournent complètement le cache. Par exemple, une lecture peut lire une donnée, mais celle-ci ne sera pas chargée dans le cache, elle passe directement de la RAM vers les registres. Une section entière de ce chapitre sera dédiée au contournement du cache, à savoir aux situations où les accès mémoire doivent passer directement du processeur à la RAM sans passer par le cache. D'autres instructions assez rares incorporent des indications pour le cache. Par exemple, l'instruction ''load last'' des processeurs POWER PC implique que la donnée ne sera utilisée qu'une seule fois. Elle est donc chargée dans le cache, mais la ligne de cache est configurée de manière à être remplacée très rapidement, typiquement avec une valeur de LRU/LFU adéquate. La donnée est bien chargée dans le cache, au cas où elle doive être relue suite à une mauvaise prédiction de branchement ou autre, chose qu'une lecture non-temporelle (qui contourne le cache) ne fait pas. Des indications de ce type sont appelées des '''''cache hint'''''. L''''instruction ''flush''''' permet de préciser qu'une ligne de cache contient une donnée inutile, qui ne sera pas réutilisée par le programme. Pas besoin de la conserver dans le cache, elle peut laisser sa place à des données plus utiles. Or, sans indication, les algorithmes de remplacement d'une ligne de cache risquent de conserver cette donnée trop longtemps, ce qui entraine une certaine pollution du cache par des données inutiles. Une autre instruction est elle beaucoup plus importante : celle de '''pré-allocation sur écriture'''. Elle sert dans le cas où une ligne de cache est complétement écrite. Par exemple, imaginons qu'on veuille écrire dans une portion de mémoire. Si celle-ci n'est pas dans le cache, le processeur va charger une ligne de cache complète depuis la RAM, écrire dans la ligne de cache, puis recopier la ligne de cache modifiée en mémoire RAM. Une écriture en RAM demande donc de faire une lecture et une écriture. Mais les instructions de pré-allocation sur écriture permettent de prévenir qu'une ligne de cache sera intégralement écrite, et qu'il n'y a donc pas besoin de lire celle-ci depuis la RAM. Notons que l'instruction d'écriture qui suit n'est pas une écriture non-temporelle, vu que les données sont écrites dans la ligne de cache, qui est ensuite envoyée en mémoire RAM dès que nécessaire. De plus, les données écrites peuvent ensuite être relue depuis le cache si nécessaire. Enfin, certains processeurs MIPS incorporent une instruction pour modifier le tag d'une ligne de cache. Elles servent à optimiser les copies mémoire, à savoir quand on copie un bloc de données d'un endroit à un autre. L'idée est de charger le bloc de données dans le cache avec une instruction LOAD/PREFETCH, de modifier le tag pour qu'il pointe vers l'adresse à écrire, et de laisser faire le cache pour que l'écriture se fasse en RAM. Mais les contraintes pour utiliser cette instruction sont assez drastiques : les données doivent être alignées sur la taille d'une ligne de cache, le bloc de départ et d'arrivée (l'original versus la copie) ne doivent pas se recouvrir, etc. ==L'associativité des caches et leur adressage implicite== Lorsqu'on souhaite accéder au cache, il faut trouver quelle est la ligne de cache dont le tag correspond à l'adresse demandée. On peut classifier les caches selon leur stratégie de recherche de la ligne correspondante en trois types de caches : totalement associatifs, directement adressés (''direct mapped'') et associatifs par voie. ===Les caches totalement associatifs=== Avec les caches totalement associatifs, toute donnée chargée depuis la mémoire peut être placée dans n'importe quelle ligne de cache, sans aucune restriction. Ces caches ont un taux de succès très élevé, quand on les compare aux autres caches. [[File:Cache totalement associatif.png|centre|vignette|upright=2|Cache totalement associatif.]] Concevoir un cache totalement associatif peut se faire de deux grandes manières différentes. La première consiste tout simplement à combiner une mémoire associative avec une mémoire RAM, en ajoutant éventuellement quelques circuits annexes. La mémoire associative mémorise les tags, alors que la mémoire RAM mémorise les données de la ligne de cache, éventuellement avec quelques bits de contrôle. La ligne de cache est stockée à une adresse A dans la mémoire RAM et son tag est stocké à la même adresse, mais dans la mémoire CAM. Ce faisant, quand on envoie le tag à la mémoire CAM, elle renvoie l'adresse de la ligne de cache dans la mémoire RAM. Cette adresse est alors envoyée directement sur le bus d'adresse de la RAM, et la lecture est effectuée automatiquement. Il faut ajouter quelques circuits annexes pour garantir que les écritures se passent correctement dans les deux mémoires, mais rien de bien terrible. [[File:Cache fabriqué avec une mémoire associative et une RAM.png|centre|vignette|upright=3|Cache fabriqué avec une mémoire associative et une RAM]] Il est cependant possible d'optimiser un tel cache, en fusionnant la mémoire CAM et la mémoire RAM, afin d'éliminer des circuits redondants. Pour comprendre pourquoi, rappelons que les mémoires CAM sont composées d'un plan mémoire, d'un paquet de comparateurs et d'un encodeur. Quant à la mémoire RAM, elle est composée d'un décodeur connecté au plan mémoire. En mettant une CAM suivie d'une RAM, on a un encodeur dont l'entrée est envoyée à un décodeur. [[File:Cache totalement associatif naif.png|centre|vignette|upright=3|Cache totalement associatif naif]] Or, le décodeur réalise l'opération inverse de l'encodeur, ce qui fait que mettre les deux composants à la suite ne sert à rien. On peut donc retirer l'encodeur et le décodeur, et envoyer directement les résultats des comparateurs sur les entrées de commande du plan mémoire de la RAM. [[File:Cache totalement associatif optimisé.png|centre|vignette|upright=2|Cache totalement associatif optimisé]] Avec cette méthode, les circuits du cache ressemblent à ce qui illustré ci-dessous. Le tag est envoyé à chaque ligne de cache. Le tag envoyé est alors comparé avec le Tag contenu dans chaque ligne de cache, comme c'est le cas sur les mémoires associatives. Si une ligne de cache matche avec le tag envoyé en entrée, la ligne pour laquelle il y a eu une égalité est alors connectée sur les lignes de bit (''bitlines''). Cela est réalisé par un circuit commandé par le comparateur de la ligne de cache. Il ne reste plus qu'à sélectionner la portion de la ligne de cache qui nous intéresse, grâce à un paquet de multiplexeurs. Cela permet d'effectuer une lecture ou écriture, mais il faut aussi préciser si il y a eu un défaut de cache ou un succès. Un succès de cache a lieu quand au moins des comparaisons est positive, alors que c'est un défaut de cache sinon. En clair, détecter un succès de cache demande juste de connecter une porte OU à plusieurs entrées à tous les comparateurs. [[File:Organisation générale d'un cache totalement associatif.png|centre|vignette|upright=2|Organisation générale d'un cache totalement associatif.]] ===Les caches directement adressés=== Les caches directement adressés peuvent être vus comme un cache totalement associatif auquel on aurait ajouté des restrictions assez drastiques. Plus haut, on a vu qu'un cache totalement adressé est équivalent à la combinaison d'une CAM avec une RAM. La mémoire CAM prend en entrée un Tag et traduit celui-ci en une adresse qui commande la mémoire RAM interne au cache. Dans ce qui suit, l'adresse interne au cache sera appelé l''''indice''' pour éviter toute confusion. [[File:Cache hash table - 2.png|centre|vignette|upright=2|Fonctionnement interne du cache, expliquée sous forme abstraite, en utilisant la notion d'indice interne au cache.]] Les caches directement adressés cherchent à remplacer la mémoire CAM par un circuit combinatoire. Ce circuit traduit le Tag en indice, mais est beaucoup plus simple qu'une mémoire CAM. Mais qui dit circuit plus simple dit circuit plus limité. Un circuit combinatoire n'est pas aussi versatile que ce qui est permis avec une mémoire CAM. En conséquence, une restriction majeure apparait : toute adresse mémoire est associée dans une ligne de cache prédéfinie, toujours la même. L'association entre ligne de cache et adresse mémoire est faite par le circuit combinatoire, et ne peut pas changer. Les concepteurs de caches s'arrangent pour que des adresses consécutives en mémoire RAM occupent des lignes de cache consécutives, par souci de simplicité. Tout se passe comme suit la mémoire RAM était découpés en blocs de la même taille que le cache. La première adresse du bloc est associée à la première ligne de cache (celle d'indice 0), la seconde adresse est associée à la seconde adresse du_ bloc, et ainsi de suite. Le tout est illustré ci-dessous. [[File:Cache adressé directement.png|centre|vignette|upright=2|Cache adressé directement.]] Avec cette contrainte, le circuit de traduction de l'adresse en adresse mémoire pour la RAM interne au cache est drastiquement simplifié, et disparait même. Une partie de l'adresse mémoire sert à indiquer la position de la donnée dans le cache, le reste de l'adresse sert encode le tag et la position de la donnée dans le ligne de cache. [[File:Cache line.png|centre|vignette|upright=2|Adresse d'une ligne de cache sur un cache adressé directement.]] Un cache directement adressé est conçu avec une RAM, un comparateur, et un paquet de multiplexeurs. En général, la mémoire RAM stocke les lignes de caches complète. Il arrive que l'on utilise deux mémoires RAM : une pour les tags et une pour les données, mais cette technique augmente le nombre de circuits et de portes logiques nécessaires, ce qui réduit la capacité du cache. L'index à lire/écrire est envoyé sur l'entrée d'adresse de la RAM, la RAM réagit en mettant la ligne de cache sur sa sortie de donnée. Sur cette sortie, un comparateur compare le tag de la ligne de cache lue avec le tag de l'adresse à lire ou écrire. On saura alors si on doit faire face à un défaut de cache. Ensuite, un multiplexeur récupère la donnée à lire/écrire. [[File:Direct mapped cache - french.png|centre|vignette|upright=2|Cache directement adressé.]] L'accès à un cache directement adressé a l'avantage d'être très rapide vu qu'il suffit de vérifier une seule ligne de cache : celle prédéfinie. Mais ces caches ne sont cependant pas sans défauts. Vu que le cache est plus petit que la mémoire, certaines adresses mémoires se partagent la même ligne de cache. Si le processeur a besoin d’accéder fréquemment à ces adresses, chaque accès à une adresse supprimera l'autre du cache : tout accès à l'ancienne adresse se soldera par un défaut de cache. Ce genre de défauts de cache causés par le fait que deux adresses mémoires ne peuvent utiliser la même ligne de cache s'appelle un '''défaut par conflit''' (''conflict miss''). Les défauts par conflit n'existent pas sur les caches totalement associatifs. En conséquence, le taux de succès des caches directement adressés est assez faible comparé aux autres caches. [[File:Cache Block Basic Conflict.svg|centre|vignette|upright=1.5|Exemple de ''Conflict Miss''.]] ===Les caches associatifs par voie=== Les caches associatifs par voie sont un compromis entre les caches directement adressés et les caches totalement associatifs. Pour simplifier, ces caches sont composés de plusieurs caches directement adressés accessibles en parallèle, chaque cache/RAM étant appelé une '''voie'''. Avec ces caches, toute adresse mémoire en RAM est associée à une ligne de cache dans chaque voie. [[File:Cache associatif par voie.png|centre|vignette|upright=2|Cache associatif par voie.]] Le schéma ci-dessous compare un cache directement adressé et un cache associatif à deux voies. On voit que chaque adresse est associée à une ligne de cache bien précise avec un cache directement dressé, et à deux lignes de cache avec un cache associatif à deux voies. L'adresse sera associée à 4 lignes de cache sur un cache associatif à 4 voies, à 8 lignes pour un cache à 8 voies, etc. L'ensemble des lignes de cache associées à une adresse est appelé un '''ensemble'''. [[File:Cache Fill.svg|centre|vignette|upright=2|Comparaison entre un cache directement adressé et un cache associatif à deux voies.]] Sur ces caches, toute adresse est découpée en trois parties : un tag, un index, et un décalage, comme sur les caches directement adressés. Comme vous pouvez le voir, l'organisation est identique à celle d'un cache totalement associatif, à part que chaque ensemble tag-ligne de cache est remplacé par une mémoire RAM qui en contient plusieurs. [[File:Implémentation d'un cache associatif par voie.png|centre|vignette|upright=2|Implémentation d'un cache associatif par voie.]] Le risque de conflits d'accès au cache est donc réduit sur un cache associatif à plusieurs voies, et il est d'autant plus réduit que le cache a de voies. Par contre, leur conception interne fait qu'ils ont un temps d'accès légèrement élevé que les caches directement adressés. Les caches associatifs par voie ont donc un taux de succès et un temps d'accès intermédiaire, situé entre les caches directement adressés et totalement associatifs. Ils sont une sorte de compromis entre réduction des défaut par conflits d'accès au cache et temps d'accès, et complexité des circuits. ==Les optimisations des caches associatifs par voie== Les caches partiellement associatifs regroupent les caches associatifs par voie et directement adressés, ainsi que leurs variantes. En clair : tous les caches qui ne sont pas totalement associatifs. Ils peuvent être optimisés de nombreuses manières, que ce soit pour gagner en performance ou pour économiser de l’énergie. Dans cette section, nous allons voir quelles sont ces optimisations. ===Les caches pseudo-associatifs=== Les caches adressés par voie contiennent une mémoire SRAM par voie. En théorie, les voies sont accédées en parallèles, en même temps, afin de voir si l'on a un succès de cache ou un défaut. Les '''caches pseudo-associatifs''' sont identiques aux caches associatifs par voie, si ce n'est qu'ils vérifient chaque voie une par une. Ils ont été utilisés sur des processeurs commerciaux, un exemple étant l'IBM 370. Là encore, on perd en performance pour gagner en consommation d'énergie. Le temps d'accès dans le meilleur des cas est plus faible pour les caches pseudo-associatifs, mais le pire des cas teste tous les caches avant de tomber sur le bon. Les performances sont donc réduites. Mais la consommation énergétique est meilleure, vu qu'on ne vérifie pas forcément toutes les voies en parallèle. On teste la première voie, éventuellement la seconde, peut-être la troisième, etc. Mais dans le cas général, on ne teste qu'une partie des voies, pas toutes, ce qui donne un gain en termes d'énergie. L'implémentation de caches de ce genre demande que l'on parcoure les voies une par une, en commençant de la première jusqu'à la dernière. Pour cela, un simple compteur suffit. Suivant la valeur du compteur, la voie associée est activée puis accédée. Toute la complexité revient à ajouter un circuit qui prend la valeur du compteur, et active la voie associée, lance un accès mémoire dessus. Vu que les voies sont chacune des caches ''direct mapped'', il suffit pour cela de geler les entrées d'adresse, soit en les déconnectant, soit en utilisant du ''clock gating'' ou de l'évaluation gardée. Les détails d'implémentation, non-cités ici, varient selon le cache. ===La prédiction de voie=== Pour réduire le temps d'accès des caches pseudo-associatifs, certains chercheurs ont inventé la '''prédiction de voie''', qui consiste à faire des paris sur la prochaine voie accédée. L'idée est d'accéder à la voie qui contient la donnée voulue du premier coup, en lisant celle-ci en priorité. Dans son implémentation la plus simple, le cache reste un cache pseudo-associatif. Lors d'un accès au cache, les voies sont toutes parcoures une par une. Par contre, les voies ne sont donc pas parcourues de la première vers la dernière, mais dans un ordre différent. Cette technique permet de mettre en veille les voies sur lesquels le processeur n'a pas parié, ce qui permet de diminuer la consommation énergétique du processeur. C'est plus efficace que d'aller lire plusieurs données dans des voies différentes et de n'en garder qu'une. L'implémentation est assez simple : il suffit d'ajouter un circuit de prédiction de voie,relié au compteur de voie. Une amélioration de la technique fait fonctionner le cache comme un intermédiaire entre cache pseudo-associatif et associatif par voies. L'idée est de chercher la voie prédite en premier, puis de chercher dans toutes les voies en parallèle en cas de défaut de cache. Au lieu d'attendre que les comparaisons de tags donnent leur résultat, le processeur sélectionne automatiquement une voie et configure les multiplexeurs à l'avance. Si le processeur ne se trompe pas, le processeur accède à la donnée plus tôt que prévu. S'il se trompe, le processeur annule la lecture effectuée en avance et recommence en faisant un accès en parallèle aux autres voies. Le compromis entre performance et consommation d'énergie est alors différent. On économise de l'énergie par rapport à un cache associatif par voie, au prix d'une petite perte de performance (doublement des temps d'accès). Mais par rapport à un cache pseudo-associatif, l'économie d'énergie est bien moindre, au prix d'un gain en performance assez manifeste. Prédire quelle voie sera la bonne est assez simple. En vertu du principe de localité, les accès futurs ont des chances de tomber dans les voies les plus fréquemment utilisées ou dans celle plus récemment utilisée. Il suffit de retenir la voie la plus récemment accédée dans un registre, qui sera utilisée comme prédiction. Pour vérifier que la prédiction est correcte, il suffit de comparer le registre et le résultat obtenu après vérification des tags. Cependant, on peut complexifier l'implémentation pour prendre en compte l'adresse à lire/écrire, l'instruction à l'origine de l'accès mémoire ou tout autre paramètre utile. Par exemple, des instructions différentes ont tendance à aller chercher leurs données dans des ensembles différents et la voie à choisir n'est pas la même. Pour cela, il suffit d'utiliser un cache pour stocker la correspondance instruction - voie. Pour plus de simplicité, la mémoire cache des prédictions est parfois remplacée par une RAM, qui est adressée : * soit par le program counter de l'instruction à l'origine de l'accès (en réalité, seulement quelques bits de poids faible de l'adresse) ; * soit par l'adresse à accéder (là encore, quelques bits de poids faible) ; * soit (pour les modes d'adressage qui utilisent un registre de base et un décalage) par un XOR entre les bits de poids faible de l'adresse de base et le décalage ; * soit par autre chose. ===La mise en veille sélective des voies=== Les caches associatifs ont tendance à utiliser beaucoup d'énergie, même quand on n'y accède pas. Aussi, certains processeurs détectent quand le cache est peu utilisé et en profitent pour mettre en veille les voies inutilisées. Vous vous demandez certainement ce qui se passe quand une donnée à lire/écrire est dans une voie désactivée. La réponse est que le cache détecte cette situation, car elle déclenche un succès de cache. Les ''tags'' ne sont en effet pas désactivés, seules les données sont mises en veille. L'implémentation est plus simple sur les caches qui séparent les tags et les données dans deux RAM différentes. Cette optimisation marche surtout sur les gros caches, qui ont des chances d'avoir une portion significative d’inutilisée (pas assez de données pour les remplir), donc généralement les caches L3/L4. Par exemple, les processeurs d'Intel de microarchitecture Ivy Bridge disposent d'un cache de 8 mébioctets à 16 voies, qu'ils peuvent faire passer à 512 kibioctets si le besoin s'en fait sentir. Quand ces processeurs détectent une faible activité, ils mettent en veille 14 voies et n'en gardent que 2 d'actives. Évidemment, les 14 voies sont vidées avant d'être mises en veille, afin qu'une aucune donnée ne soit perdue. ===Les caches ''skew-associative''=== Vous aurez remarqué que dans une voie, les lignes sont accédées en adressage direct : les défauts par conflit sont possibles sur un cache associatif par voie. Pour éviter cela, certains chercheurs ont créé des '''caches ''skew associative''''' (ou associatifs à biais). Pour faire simple, les index des lignes de cache subissent un petit traitement avant d'être utilisés. Le traitement en question est différent suivant la voie de destination, histoire que deux adresses mémoires avec des index identiques donnent des index différents après traitement. Le traitement en question est souvent une permutation des bits de l'index, qui est différente suivant la voie prise, ou un simple XOR avec un nombre qui dépend de la voie. [[File:Implémentation d'un cache skew associative.jpg|centre|vignette|upright=2|Implémentation d'un cache skew associative.]] ==Les caches splittés (''phased caches'')== Dans cette section, nous allons voir les '''caches splittés''' (''phased caches''), qui sont une variante des caches ''direct-mapped'', dans lequel le cache est accédé en deux étapes consécutives. Il ne s'agit pas des caches pipelinés, que nous verrons dans le chapitre sur les processeurs pipélinés, mais laissons cela à plus tard. Il est possible d'appliquer la même méthode sur un cache associatif par voie, mais il y a des méthodes plus simples, qui permettent là aussi d’accéder au cache en plusieurs étapes consécutives. L'idée est de scinder le cache en deux : une mémoire pour les tags, une autre pour les données de la ligne de cache. Les bits de contrôle peuvent être mis dans l'une ou l'autre SRAM, mais ils sont souvent mis dans la RAM pour les tags. En faisant cela, quelques optimisations deviennent possibles, afin de réduire la consommation énergétique en contrepartie d'une perte de performance. La technique s'implémente différemment pour les caches totalement associatifs et partiellement associatifs. Les caches totalement associatifs splittés sont ceux formés en combinant un cache associatif avec une CAM et une RAM combinée. On envoie l'adresse à lire/écrire à la mémoire associative, elle répond en envoyant une adresse à la mémoire RAM. L'accès se fait donc en deux temps, avec l'adresse dans la RAM comme intermédiaire. Il est possible de séparer physiquement les deux étapes en insérant un registre entre la CAM et la RAM, ce qui permet aussi de pipeliner l'accès. Mais c'est rarement fait en pratique, car le cout en circuit d'une mémoire CAM est trop important. L'équivalent pour un cache totalement associatif optimisé, sans CAM et RAM séparée, est trop gourmande en interconnexions pour être implémentée. Les caches totalement associatifs splittés sont donc très rares, l'auteur ne connait aucun exemple de processeur avec un tel cache. Il existe une technique équivalente pour les caches ''direct-mapped'', mais elle demande une certaine modification du cache. Dans les caches ''direct-mapped'' non-splittés, on trouve une mémoire SRAM dont chaque mot mémoire contient une ligne de cache entière, tag inclus. Dans leurs versions splittés, la SRAM est séparée en deux : une pour les tags, une autre pour les données. Précisons qu'il s'agit bien de deux mémoires SRAM adressables. L'adresse à laquelle accéder est envoyée à la SRAM des tags, puis ensuite à la SRAM des données si besoin. L'idée est d’accéder aux tags pour déterminer s'il y a un succès de cache ou un défaut, et ensuite d'accéder aux données. On n’accède pas aux données en parallèle des tags. Faire cela est évidemment plus lent. En cas de défaut de cache, le temps d'accès est similaire : le tag ne correspond pas, on n'accède pas à la SRAM pour les données. Par contre, vu qu'on n'a pas activé la SRAM pour les données, on économise un peu d'énergie, ce qui réduit la consommation d'énergie. En cas de succès de cache, on accède à la SRAM pour les tags, puis à celle pour les données. Pas d'économie d'énergie à l'horizon, sans compter que le temps d'accès augmente : on accède au cache en deux étapes au lieu de faire les deux accès en parallèle. [[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]] Précisons cependant que ce design peut avoir deux avantages en termes de performance. Premièrement, le temps d'accès au cache est légèrement amélioré en cas de défaut de cache. En effet, la SRAM des tags est assez petite, idem pour celle des données. Leur temps d'accès est donc plus faible que pour une grosse SRAM contenant données et tags. Le gain en temps d'accès est donc un avantage, qui ne se manifeste surtout en cas de défaut de cache. Un autre avantage est que l'accès au cache se pipeline plus facilement, ce qui fait qu'on peut effectuer plusieurs accès simultanés au cache. Mais nous verrons cela dans quelques chapitres. ===Le contrôleur de cache 82385 pour les CPU Intel 386=== Il est important de noter que la séparation entre tags et RAM peut être telle que les deux ne sont pas sur la même puce de silicium ! Voire que les deux sont séparés du processeur ! C'était le cas quand les mémoires caches ont été introduites sur les processeurs grand public, notamment sur les premiers processeurs Intel. La miniaturisation n'avait pas avancé au point où placer un cache dans le processeur était possible. Sur le processeur 386 d'Intel, le cache était un cache splitté, séparé du processeur. Concrètement, le processeur i386 était couplé à un contrôleur de cache Intel 82385 et une mémoire SRAM. Le 82385 contenait les ''tags'' et les bits de contrôle, la SRAM contenait les données, les lignes de cache. Un point important est que les lignes de cache faisaient seulement 32 bits/4 octets, pas plus ! On était loin des lignes de cache actuelles, faisant 64 octets/512 bits. Mais c'était beaucoup plus pratique, vu que le bus système faisait 32 bits de large, idem pour l'interface avec le processeur. Pour intégrer un cache facultatifs (certains ordinateurs 'en passaient). Le schéma ci-dessous montre comment le cache s'intégrait avec le bus système. Pour le bus de commande, le cache servait d'intermédiaire : il recevait les commandes et et les filtrait suivant les succès/défauts de cache. En cas de succès de cache, les commandes de lecture n'étaient pas envoyées à la mémoire RAM. Les adresses étaient transmises à la fois au cache et au bus système (avec un registre entre le bus système et le processeur). Le bus de donnée était lui connecté à la mémoire SRAM et au processeur, avec des MUX/DEMUX pour faire le choix de la source des lectures. [[File:Controleur de cache 82385 pour l'Intel 386.png|centre|vignette|upright=2.5|Contrôleur de cache 82385 pour l'Intel 386]] Le 82385 surveillait ce qui se passait sur le bus et répondait à la place de la RAM pour certaines lectures. C'était un intermédiaire assez passif, qui se contenait de répondre aux succès et défauts en lecture. Le cache était un cache ''write through'' un peu particulier. En cas de succès de cache pour une écriture, le cache met à jour sa ligne de cache et propage l'écriture en mémoire RAM. Par contre, si une écriture fait un défaut de cache, la donnée n'est pas écrite dans le cache. Le seul moyen pour copier une donnée dans le cache était un défaut pour une lecture. Le 82385 pouvait commander soit un cache ''direct mapped'', soit associatif à deux voies. La différence entre les deux est que le 82385 était relié à une mémoire SRAM avec un cache ''direct mapped'', deux SRAM pour deux voies. Pour avoir un cache associatif à deux voies, le 82385 devrait gérer deux signaux ''chip select'' pour activer chaque SRAM/voie suivant les besoins. Il avait précisément quatre signaux CS : deux par SRAM, un pour les lectures, un pour les écritures. Notons que les SRAM utilisées devaient être des SRAM avec un bus de données de 32 bits, des SRAM 16 ou 8 bits ne fonctionnaient pas. Le 82385 rajoutait 4 sorties, pour masquer chaque octet dans ces 32 bits. Le choix entre les deux était le fait d'une entrée : la mettre à 0 indiquait un cache ''direct mapped'', la mettre à 1 forçait un cache à deux voies. [[File:Interface entre le 82385 et la SRAM du cache.png|centre|vignette|upright=2|Interface entre le 82385 et la SRAM du cache]] Il gérait aussi les accès mémoire non-cacheable, à savoir des accès mémoire qui ne doivent pas être pris en compte par le cache. Il considérait certains accès mémoire comme "à ne pas cacher". Notamment, les accès mémoire à une entré-esortie ne sont pas cachés. Pour rappel, le processeur utilisait un espace d'adressage séparé pour les entrées-sorties, et utilisait donc un bit IO, qui était utilisé par le 82385 pour savoir si l'accès mémoire doit être caché ou non. Il en est de même pour les accès ayant lieu lors d'une interruption, qui ne passent pas par le cache. Mais au-delà de cette inhibition automatique du cache, le 82385 avait une entrée NCA (''Non Cacheable Access'') : le cache était "désactivé" quand cette entrée était à 1. C'est un peu une sorte de ''chip select'' pour le 82385, limitée aux accès mémoire. Cette entrée permettait de programmer des intervalles d'adresse auxquels ne pas répondre, en utilisant des circuits de décodage d'adresse adaptés. Il avait aussi une entrée X16, qui permettait d'identifier les accès soit à un composant 16 bits. De tels accès ne doivent pas être mis en cache, sans doute parce que cela ne collait pas avec la taille des lignes de cache (32 bits). Et cette entrée permettait d'inhiber ces accès 16 bits d'agir sur le cache, en utilisant le bit du bus de commande adéquat. Le 82385 pouvait être intégré dans un système à deux processeurs, voire plus. Pour cela, chaque processeur avait son propre 82385 et sa SRAM rien qu'à lui. Il n'y avait pas de cache partagé entre les deux processeurs. Par contre, les deux caches étaient reliés au même bus système. Pour qu'ils ne se marchent pas sur les pieds, il y avait des circuits d'arbitrage pour gérer l'accès au bus. Un des deux 82385 était mis en mode maitre, l'autre était en mode esclave. Le 82385 maitre pouvait prendre le contrôle du bus, le 82385 esclave devait demander l'autorisation au premier pour accéder au bus système. Le 82385 gérait une forme limitée de cohérence des caches par invalidation. Dès que le 82385 détectait une prise de contrôle du bus par autre chose que le processeur, il surveillait les adresses transmises sur le bus. En cas de succès de cache, la ligne de cache associée était invalidée. Au-delà de ca, le 82385 avait une entrée FLUSH, qui ordonnait une invalidation totale du cache. Si cette entrée est mise à 1, toutes les lignes de cache sont invalidées. Les ''tags'' sont marqués comme invalides, mais les lignes de cache elles-mêmes ne sont pas touchées. ===L'exemple des processeurs Intel de microarchitecture ''Broadwell''=== Un autre exemple est celui du cache L4 des processeurs Broadwell et de quelques processeurs séparés. Ces processeurs ont une organisation en ''chiplet'' où le processeur incorpore plusieurs puces séparées : une puce pour le processeur proprement dit, une puce nommée ''Crystal Well'' pour le cache L4, et une puce IO pour la communication avec la RAM et la carte mère. Le processeur incorporait un cache L4 de 128 mébioctets, composé de mémoire eDRAM, qui était dispersé entre ''Crystal Well'' et les autres puces. Les données du cache L4 étaient dans ''Crystal Well'', alors que les Tags étaient soit dans le processeur lui-même, soit dans la puce IO ! La puce ''Crystal Well'' était une mémoire DRAM adressable tout ce qu'il y a de plus basique, avec cependant quelques optimisations notables. Par exemple, elle avait deux bus séparés pour l'écriture et la lecture. De plus, elle avait une organisation interne avec 128 banques, contre moins d'une dizaine pour la DDR de l'époque et environ 32 banques pour la DDR5 moderne. Elle contenait aussi quelques circuits pour gérer son rôle de mémoire cache, mais rien en ce qui concerne la gestion des tags eux-mêmes. Sur les processeurs de microarchitecture ''Broadwell'', les tags étaient placés dans le CPU et précisément dans le cache L3. À chaque accès mémoire au cache L3, les tags du cache L4 étaient consultés en parallèle. De fait, l'accès au cache L4 était assez rapide, malgré le fait que les données étaient dans une puce à part. Ajoutons à cela que le processeur et ''Crystal Well'' n'avaient pas la même finesse de gravure ni la même technologie de fabrication. Les tags étaient implémentés avec de la SRAM contre la DRAM pour les données, ce qui fait que la consultation des tags était plus rapide que l'accès aux données. Par la suite, dans certains CPU de microarchitecture ''skylake'', les tags ont été déplacés en-dehors du processeur pour finir dans le contrôleur mémoire. En faisant cela, le cache L4 pouvait être utilisé par autre chose que le processeur, et notamment par la carte graphique intégrée au CPU. Avec ''broadwell'', le fait que les tags étaient consultés en cas d'accès au L3 empêchait au GPU intégré de consulter le cache L4. Mais en déplaçant les tags dans le contrôleur mémoire, ce n'est plus le cas vu que la carte graphique a aussi accès au bus mémoire. Par contre, le temps d'accès augmente comparé à la solution précédente. On n'accède pas aux tags du L4 en parallèle du L3 : à la place, il faut consulter les tags du L3, détecter un défaut de cache L3, et ensuite accèder aux tags. ===Les caches RAM-configurables=== Un autre avantage des caches splittés est qu'on peut les modifier pour servir à la fois de mémoire cache, mais aussi de ''local store'', de mémoire RAM de petite taille. Le fonctionnement est assez simple à comprendre. Lors d'un accès au cache, on accède aux tags, puis à la RAM interne au cache. Lors d'un accès au ''local store'', on contourne l'accès au tags et on accède à la RAM interne au cache directement. Il s'agit de la technique du '''cache RAM-configurable''. L'usage de cache RAM-configurable est fréquent sur les cartes graphiques récentes, qui incorporent un ou plusieurs processeurs multicoeurs, dont le cache L1 de données est un cache RAM-configurable. [[File:Hydride cache - local store.png|centre|vignette|upright=2.0|Hydride cache - local store]] ===La compression de cache=== Une autre optimisation permise par les ''phased caches'' est l'implémentation de techniques de '''compression de cache''', qui visent à compresser des lignes de cache. L'intérêt est qu'on peut stocker plus de données dans le cache, à capacité égale. L'inconvénient est qu'on doit compresser/décompresser les lignes de cache, ce qui demande un circuit en plus et allonge les temps d'accès. En effet, le temps mis pour compresser/décompresser une ligne de cache s'ajoute au temps d'accès. Aussi, la compression de cache sert surtout pour les caches de bas niveau dans la hiérarchie mémoire, les gros caches aux temps d'accès assez longs. Une première technique, assez simple à implémenter et peu couteuse en circuit, est celle de la '''compression des lignes de cache nulles'''. Elle compresse uniquement les lignes de cache qui ne contiennent que des zéros. L'idée est qu'on ajoute, dans la mémoire des tags, un bit de contrôle pour chaque ligne de cache appelé le bit ''null''. Il indique si la ligne de cache ne contient que des zéros. Quand on lit une ligne de cache, la mémoire des tags est accédée et on vérifie le bit ''null'' : s'il vaut 1, on n'accède pas à la mémoire cache de données et un multiplexeur envoie un zéro sur le port de lecture. Le bit ''null'' est fixé lors de l'écriture d'une ligne de cache : elle passe dans un comparateur avec zéro relié à la mémoire des tags. La comparaison avec zéro peut se faire en parallèle de l'écriture ou avant (dans ce cas, on n'écrit pas la ligne de cache dans le cache). Les autres techniques de compression de cache permettent de compresser autre chose que des lignes de cache nulles. L'idée est qu'une ligne de cache physique peut par moment mémoriser plusieurs lignes de caches compressées. Par exemple, prenons un cache dont les lignes de cache font 64 octets. Il est possible de compresser deux lignes de cache pour qu'elles fassent chacune 32 octets, et les stocker dans une seule ligne de cache. Les deux lignes de cache auront des tags différents, mais pointeront sur la même ligne de cache physique. Et cela demande d'utiliser un ''phased cache'' dont la mémoire pour les tags est plus grande que la mémoire pour les données. Il n'y a donc plus une bijection entre tags et ligne de cache, mais une relation surjective. Chose qui n'est possible qu'avec un ''phased cache''. De plus, des bits de contrôles associés à chaque ''tag'' indiquent où se trouvent les lignes de cache compressées dans la ligne de cache : est-ce que c'est les 32 octets de poids fort ou de poids faible ? [[File:Compression de cache.png|centre|vignette|upright=2|Compression de cache]] Il ne semble pas que les techniques de compression de cache soient implémentées sur les processeurs modernes. Aucun n'utilise de compression de cache, à ma connaissance. Il faut dire que les techniques connues sont de mauvais compromis : le temps d'accès du cache augmente beaucoup, le cout en circuit pourrait être utilisé pour un cache non-compressé mais plus grand. Et notons que la compression de cache ne marche que si les données peuvent se compresser. Si ce n'est pas le cas, une partie de la mémoire des tags est inutilisée. Une revue de la littérature académique sur la compression de cache est disponible via ce lien, pour les curieux : * [https://inria.hal.science/hal-03285041 Understanding Cache Compression, par Carvalho et Seznec]. ==L'adressage physique ou logique des caches== Le cache utilise les adresses à lire/écrire pour déterminer s'il a une copie de la donnée en son sein. Mais l’interaction entre caches et mémoire virtuelle donne lieu à un petit problème : l'adresse utilisée est-elle une adresse virtuelle/logique ou physique ? La réponse varie suivant le processeur : certains caches utilisent l'adresse virtuelle, tandis que d'autres prennent l'adresse physique. On parle de cache '''virtuellement tagué''' dans le premier cas et de cache '''physiquement tagué''' dans le second. {| |[[File:Cache tagué virtuellement.png|vignette|Cache tagué virtuellement.]] |[[File:Cache tagué physiquement.png|vignette|Cache tagué physiquement.]] |} ===L'accès à un cache physiquement/virtuellement tagué=== La manière d'accéder à un cache dépend de s'il est virtuellement ou physiquement tagué. Il faut utiliser l'adresse virtuelle pour les premiers, physique pour les seconds. Avec un cache virtuellement tagué, l'adresse logique peut être envoyée directement au cache. La MMU ne traduit les adresses que s'il faut accéder à la mémoire RAM. Ces caches sont donc plus rapides. Avec un cache physiquement tagué, le processeur doit traduire l'adresse logique en adresse physique dans la MMU, avant d'accéder au cache. La traduction d'adresse se fait soit en accédant à une table des pages en mémoire RAM, soit en accédant à un cache spécifiquement dédié à accélérer la traduction d'adresse, la TLB (''Translation Lookaside Buffer''). Dans la quasi-totalité des cas, la traduction d'adresse passe par la TLB, ce qui fait qu'elle est raisonnablement rapide. Toujours est-il que chaque accès au cache demande d'accéder à la TLB et de faire la traduction d'adresse avant d'accéder au cache. L'accès est donc plus lent que sur les caches virtuellement tagués, où les accès sont plus directs. [[File:Virtual and Physical addressing.svg|centre|vignette|upright=2|Cache tagué virtuellement versus physiquement tagué.]] ===Les défauts des caches virtuellement tagués=== Les caches physiquement tagués sont moins rapides que les caches virtuellement adressés. Pourtant, les caches virtuellement tagués sont peu fréquents sur les processeurs modernes. Et la raison est assez intéressante : c'est une question d'adresses homonymes et synonymes. ====Les droits d'accès doivent être vérifiés lors d'un accès au cache==== Un premier problème est que la protection mémoire est compliquée avec de tels caches. Rappelons que certaines portions de mémoire sont accessibles seulement en lecture, ou sont interdites en écriture, sont inexécutables, etc. Ces droits d'accès sont gérés par la MMU, qui vérifie pour chaque accès mémoire que l'accès est autorisé. En bypassant la MMU, l'accès au cache virtuellement tagué ne permet pas de faire ces vérifications. Il est possible de charger une donnée en lecture seule dans le cache, mais d'y faire des accès en écriture pour les accès ultérieurs. Les solutions à cela sont multiples. La première consiste à consulter la MMU en parallèle de l'accès au cache. L'accès au cache est alors réalisé de manière spéculative, et est ensuite confirmé/annulé une fois que la MMU a rendu son verdict. Les performances du cache restent alors les mêmes : l'accès à la MMU se fait en parallèle de l'accès au cache, pas avant. Une autre solution est d'ajouter les droits d'accès en question dans la ligne de cache, dans les bits de contrôle situés après le Tag. Chaque accès au cache récupère ces bits de contrôle et vérifie si l'accès est autorisé. L'inconvénient est que les lignes de cache deviennent plus longues, les droits d'accès sont dupliqués entre MMU et cache. Mais si le budget en transistor suit, ce n'est rien d'insurmontable. ====Les adresses homonymes perturbent la gestion du cache==== Pour rappel, une adresse logique homonyme correspond à plusieurs adresses physiques différentes. Elles surviennent quand chaque programme a son propre espace d'adressage. Dans ce cas, une adresse logique correspondra à une adresse physique différente par programme.Une autre manière de voir les choses est qu'il y a en réalité deux adresses homonymes, qui ont la même valeur, mais appartiennent à des espaces d'adressage différentes. Et c'est cette seconde interprétation que nous allons utiliser. Les caches doivent gérer ces adresses homonymes et faire en sorte que la lecture/écriture d'une adresse homonyme se fasse à la bonne adresse physique, dans la bonne ligne de cache. Et autant un cache physiquement tagué n'a aucun problème avec ça, vu qu'il ne gère que des adresses physiques, autant des problèmes surviennent avec les caches virtuellement tagués. Le problème est que les caches virtuellement tagués doivent faire la différence entre deux adresses homonymes de même valeur. Pour corriger ces problèmes, il existe deux grandes méthodes. La première méthode est simple : '''vider les caches''' en changeant de programme. Leur contenu est rapatrié en mémoire RAM, puis les caches sont remis à zéro. Le vidage du cache recopie les lignes de cache ''dirty'' (modifiées) en RAM, puis efface/invalide tout le cache. C'est à cela que servent les instructions ''clean'' et d'invalidation vues plus haut, elles ont été inventées pour cette situation précise. Lorsque le système d'exploitation déclenche une commutation de contexte, à savoir qu'il change le programme en cours d'exécution, le processeur vide tous les caches du processeur. Les interruptions font la même chose, elles vide tous les caches du processeur. Une seconde méthode numérote chaque programme en cours d'exécution, chaque processus. Le numéro attribué est spécifique à chaque processus, ce qui fait qu'il est appelé un '''identifiant de processus CPU'''. Le processeur mémorise l'identifiant du programme en cours d'exécution dans un registre dédié. L'identifiant de processus CPU est utilisé lors des accès mémoire. Chaque ligne de cache contient le numéro de l'espace d'adressage associé, dans son ''tag''. Lors de chaque accès mémoire, l'ID du registre est comparé à l'ID de la ligne de cache accédée, pour vérifier que l'accès mémoire accède à la bonne donnée. Cette méthode n'est pas très économe en termes de transistors. L'usage d'identifiant de processus CPU est clairement meilleure en termes de performance, les commutations de contexte sont plus rapides. Par contre, le budget en transistor est plus important. Un autre défaut de cette méthode est que l'identifiant de processus est généralement codé sur une dizaine de bits, alors que le système d'exploitation utilise des identifiants de processus beaucoup plus larges, de 32 à 64 bits sur les CPU 32/64 bits. L'OS doit gérer la correspondance entre identifiants de processus CPU et ceux de l'OS. Parfois, pour cette raison, les OS n'utilisent pas toujours ce système d'identifiant de processus CPU. ====Les adresses synonymes perturbent aussi la gestion du cache==== La gestion des adresses synonymes est aussi un gros problème sur les caches virtuellement tagués. Pour rappel, il s'agit du cas où des adresses logiques différentes pointent vers la même adresse physique. Typiquement, quand deux programmes se partagent un morceau de mémoire, ce morceau correspondra à des adresses synonymes dans les deux espaces d'adressage. Mais il arrive que l'on ait des adresses synonymes dans le même espace d'adressage, ce n'est pas si rare ! Autant les adresses synonymes ne posent aucun problème avec les caches physiquement tagués, ce n'est pas le cas avec les caches virtuellement adressés. Sur ces caches, deux adresses logiques synonymes vont tomber dans deux lignes de cache différentes. Corriger ce problème demande d'ajouter des circuits annexes pour détecter les adresses synonymes, qui sont vraiment complexes et ont un cout en termes de performance. Aussi, les caches virtuellement tagués sont très peu utilisés sur les processeurs modernes. ===Les caches virtuellement adressés, mais physiquement tagués=== Si les caches physiquement et virtuellement tagués ont des défauts, il existe un intermédiaire qui est un bon compromis entre ces deux extrêmes. Il s'agit des '''caches virtuellement adressés - physiquement tagués''', aussi appelés '''caches pseudo-virtuels'''. Pour comprendre comment ils fonctionnent, précisons que ces caches sont soit des caches ''direct-mapped'', soit des caches associatifs par voie (composés de plusieurs RAM ''direct-mapped'' accédées en parallèle, plusieurs voies). L'accès à ce genre de cache se fait en deux temps : on accède à un ou plusieurs RAM ''direct-mapped'' et on vérifie ensuite les ''Tags'' pour sélectionner la bonne voie. Sur les caches ''direct-mapped'', on n'a qu'une seule RAM ''direct-mapped''. Sur les caches associatifs, on a plusieurs RAM ''direct-mapped'', appelées des voies, qui sont accédées en parallèle. L'accès se fait donc en deux étapes : adresser les RAM ''direct-mapped'' avec un indice, vérifier les ''tags'' avec le reste de l'adresse. Une autre chose à rappeler est que l'adresse logique est composée de deux parties : un numéro de page logique qui indique dans quel page se situe l'adresse, un décalage/''offset'' qui indique la position de l'adresse dans la page. La traduction d'adresse transforme le numéro de page logique en numéro de page physique, mais laisse le décalage intouché. L'idée est d'utiliser le décalage pour adresser les RAM avec le décalage, tandis que le numéro de page sert de ''tag''. Le décalage est découpé en deux lors de l'accès au cache : les bits de poids fort forment l'indice (l'adresse envoyée à la voie), les bits de poids faible donnent la position de l'adresse dans la ligne de cache. L'idée est d'utiliser un numéro de page physique pour les ''tags'', mais d'adresser les voies avec le décalage logique. Les deux servent à des instants différents : vérification des ''tags'' pour l'adresse physique, accès aux voies pour l'adresse logique. Ainsi, le problème des adresses synonymes ou homonymes est résolu par l'utilisation de l'adresse physique pour les tags. Par contre, l'accès au cache est plus rapide, car on utilise l'adresse logique pour la première étape. Le processeur accède à la TLB et récupère l'adresse physique pendant que l'on adresse les voies, les deux sont faits en parallèle, ce qui fait que tout se passe comme si l'accès à la TLB était gratuit. La TLB étant assez rapide comparé au cache, l'adresse physique est disponible quand on doit faire la comparaison avec les ''tags''. [[File:Virtual - Physical - Pseudo Virtual addressing.svg|centre|vignette|upright=2|Adressage pseudo virtuel des caches.]] Il s'agit d'un excellent compromis entre performance et correction des problèmes des adresses synonymes/homonymes. Tous les caches des processeurs haute performance utilisent cette méthode, au moins pour leurs caches L1. Les caches L2 tendent à utiliser des caches physiquement adressés, pour lesquels la latence d'accès est suffisante pour qu'on accède à la TLB en amont. La raison est assez simple à expliquer, elle provient d'une contrainte assez précise sur le calcul de l'indice. La conséquence est qu'un cache ''direct-mapped'' ne peut pas dépasser la taille d'une page, soit 4 kibioctets sur les ordinateurs actuels. Sur les caches associatifs, on peut dépasser cette limite en augmentant le nombre de voies, mais la taille maximale d'une voie reste celle d'une page. Cette contrainte n'est pas trop grave sur les caches de petite taille, dont les caches L1. La plupart d'entre eux ont trouvé un compromis idéal avec moins d'une dizaine de voies par cache, chacun de 4 kibioctets, ce qui donne des caches allant de 16 à 64 kibioctets, soit entre 4 et 16 voies. Par contre, un cache de grande taille doit utiliser un grand nombre de voies, ce qui est peu pratique. Aussi, cette technique de caches pseudo-virtuels n'est pas toujours appliquée sur les caches L2, qui sont physiquement adressés. Il faut dire qu'on accède au cache L2 lors d'un défaut dans le cache L1, et l'adresse physique est disponible à ce moment-là, elle a déjà été récupérée lors de l'accès au cache L1. On peut donc l'utiliser pour adresser le cache L2 sans perte de performance. ==Le remplacement des lignes de cache== Lorsqu'un cache est rempli et qu'on charge une nouvelle donnée dedans, il faut faire de la place pour cette dernière. Dans le cas d'un cache directement adressé, il n'y a rien à faire vu que la ligne de cache à évincer est déterminée lors de la conception du cache. Mais pour les autres caches, la donnée peut aller dans n'importe quelle ligne ou voie. Or, le choix des données à rapatrier en RAM doit être le plus judicieux possible : on doit virer de préférence des données inutiles. Rapatrier une donnée qui sera surement utilisée sous peu est inutile, et il vaudrait mieux supprimer des données qui ne serviront plus ou alors dans longtemps. Il existe différents algorithmes spécialement dédiés à résoudre ce problème efficacement, directement câblés dans les unités de gestion du cache. Certains sont vraiment très complexes, aussi je vais vous présenter quelques algorithmes particulièrement simples. Mais avant de voir ces algorithmes, il faut absolument que je vous parle d'une chose très importante. Quel que soit l'algorithme en question, il choisit la ligne de cache à évincer et recopie son contenu dans la RAM. Ce qui demande d'identifier et de sélectionner une ligne de cache parmi toutes les autres. Pour cela, le circuit de remplacement attribue une adresse chaque ligne de cache ! Vous avez bien vu : chaque ligne de cache est numérotée par une adresse, interne au cache. ===Le remplacement aléatoire=== Premier algorithme : la donnée effacée du cache est choisie au hasard ! C'est contre-intuitif, mais cet algorithme donne des résultats assez honorables, en plus d'utiliser très peu de portes logiques (un générateur de nombres pseudo-aléatoire est un circuit assez simple). Généralement, les défauts de cache sont séparés par un nombre assez important et irrégulier de cycles d'horloge. Dans ces conditions, cette technique donne un bon résultat. ===FIFO : first in, first out=== Avec l'algorithme FIFO, la donnée effacée du cache est la plus ancienne, celle chargée dans le cache avant les autres. Cet algorithme est très simple à implémenter en circuit, concevoir une mémoire de type FIFO n'étant pas très compliqué, comme on l’a vu dans le chapitre dédié à ce type de mémoires. Et on peut dire que dans le cas d'un cache, l'implémentation est encore plus simple et se contente d'un seul registre/compteur. Typiquement, il suffit d'ajouter un registre qui mémorise où se situe la donnée la plus récente. Toute insertion d'une nouvelle donnée se fait à l'adresse suivante, ce qui demande juste d'incrémenter le registre avant d'utiliser son contenu pour l'accès mémoire. [[File:Algorithme FIFO de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme FIFO de remplacement des lignes de cache.]] Cet algorithme possède une petite particularité sur les caches associatifs par voie : en augmentant le nombre d'ensembles, les performances peuvent se dégrader : c'est ce qu'on appelle l''''anomalie de Bélády'''. ===MRU : most recently used=== Avec l'algorithme MRU, la donnée remplacée est celle qui a été utilisée le plus récemment. Cet algorithme s'implémente simplement avec un registre, dans lequel on place le numéro de la dernière ligne de cache utilisée. Cet algorithme de remplacement est très utile quand un programme traverse des tableaux du premier élément jusqu'au dernier : les données du tableau sont rarement réutilisées, rendant le cache inutile. Il est prouvé que dans ces conditions, l'algorithme MRU est optimal. Mais dans toutes les autres conditions, cet algorithme a des performances assez misérables. ===LFU : least frequently used=== Avec l'algorithme LFU, la donnée supprimée est celle qui est utilisée le moins fréquemment. Cet algorithme s'implémente en associant un compteur à chaque ligne de cache, qui est incrémenté à chaque accès mémoire. La ligne la moins récemment utilisée est celle dont le compteur associé a la plus petite valeur. Implémenter cet algorithme prend pas mal de transistors, car il faut rajouter autant de compteurs qu'il y a de lignes de cache, en plus d'un circuit pour comparer les compteurs et d'un encodeur. [[File:Algorithme LFU de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme LFU de remplacement des lignes de cache]] ===LRU : least recently used=== Avec l'algorithme LRU, la donnée remplacée est celle qui a été utilisée le moins récemment. Cet algorithme se base sur le principe de localité temporelle, qui stipule qu'une donnée accédée récemment a de fortes chances d'être réutilisée dans un futur proche. Et inversement, la donnée la moins récemment utilisée du cache est celle qui a le plus de chance de ne servir à rien dans le futur. Autant la supprimer en priorité pour faire de la place à des données potentiellement utiles. Implémenter l'algorithme LRU peut se faire de différentes manières, qui ont pour point commun d'enregistrer les accès au cache pour en déduire la ligne la moins récemment accédée. La manière la plus simple demande d'utiliser un compteur pour chaque ligne de mémoire cache, un peu comme le LFU. La différence avec le LFU est que le compteur n'est pas incrémenté lors d'un accès mémoire. À la place, ce compteur est incrémenté régulièrement, chaque incrémentation ayant lieu en même temps pour tous les compteurs. Quand un bloc est chargé dans le cache, ce compteur est mis à zéro. Quand une ligne de cache doit être remplacée, un circuit va vérifier la valeur de tous les compteurs : la ligne LRU (la moins récemment utilisée), est celle dont le compteur a la valeur la plus haute. Le circuit est composé d'un paquet de comparateurs, et d'un encodeur, comme pour l'agorithme LFU. ===Les approximations du LRU=== Implémenter le LRU demande un nombre de transistors proportionnel au carré du nombre de lignes de cache. Autant dire que le LRU devient impraticable sur de gros caches. Ce qui fait que les processeurs modernes implémentent des variantes du LRU, moins couteuses en transistors, qui donnent un résultat approximativement semblable au LRU. En clair, ils ne sélectionnent pas toujours la ligne de cache la moins récemment utilisée, mais une ligne de cache parmi les moins récemment utilisées. Ce n'est pas un problème si grave que cela car les lignes les moins récemment utilisées ont toutes assez peu de chance d'être utilisées dans le futur. Entre choisir de remplacer une ligne qui a 0,5 % de chances d'être utilisée dans le futur et une autre qui a une chance de seulement 1 %, la différence est négligeable en termes de taux de succès. Mais les gains en termes de circuits ou de temps d'accès au cache de ces algorithmes sont très intéressants. L'algorithme le plus simple consiste à couper le cache (ou chaque voie s'il est associatif) en plusieurs sections. L'algorithme détermine la section la moins récemment utilisée, avant de choisir aléatoirement une ligne de cache dans cette section. Pour implémenter cet algorithme, il nous suffit d'un registre qui mémorise le morceau le moins récemment utilisé, et d'un circuit qui choisit aléatoirement une ligne de cache. Cette technique s'adapte particulièrement bien avec des caches associatifs à voies : il suffit d'utiliser autant de morceaux que de voies. Autre algorithme, un peu plus efficace : le '''pseudo-LRU de type M'''. Cet algorithme attribue un bit à chaque ligne de cache, bit qui sert à indiquer de façon approximative si la ligne de cache associée est une candidate pour un remplacement ou non. Il vaut 1 si la ligne n'est pas une candidate pour un remplacement et zéro sinon. Le bit est mis à 1 lorsque la ligne de cache associée est lue ou écrite. Évidemment, au fil du temps, toutes les lignes du cache finiront par avoir leur bit à 1. Lorsque cela arrive, l'algorithme remet tous les bits à zéro, sauf pour la dernière ligne de cache accédée. L'idée derrière cet algorithme est d'encercler la ligne de cache la moins récemment utilisée au fur et à mesure des accès. L'encerclement commence lorsque l'on remet tous les bits associés aux lignes de cache à 0, sauf pour la ligne accédée en dernier. Au fur et à mesure des accès, l'étau se resserre autour de la ligne de cache la moins récemment utilisée. Après un nombre suffisant d'accès, l'algorithme donne une estimation particulièrement fiable. Et comme les remplacements de lignes de cache sont rares comparés aux accès aux lignes, cet algorithme finit par donner une bonne estimation avant qu'on ait besoin d'effectuer un remplacement. Le dernier algorithme d'approximation, le '''PLURt''', se base sur ce qu'on appelle un arbre de décision. Il a besoin de n − 1 bits pour déterminer la ligne LRU. Ces bits doivent être organisés en arbre, comme illustré plus bas. Chacun de ces bits sert à dire : le LRU est à ma droite ou à ma gauche : il est à gauche si je vaux 0, et à droite si je vaux 1. Trouver le LRU se fait en traversant cet arbre, et en interprétant les bits un par un. Au fur et à mesure des lectures, les bits sont mis à jour dans cet arbre, et pointent plus ou moins bien sur le LRU. La mise à jour des bits s'effectue lors des lectures et écritures : quand une ligne est lue ou écrite, elle n'est pas la ligne LRU. Pour l'indiquer, les bits à 1 qui pointent vers la ligne de cache sont mis à 0 lors de la lecture ou écriture. {| |[[File:Organisation des bits avec l'algorithme PLURt.jpg|vignette|Organisation des bits avec l'algorithme PLURt.]] |[[File:Ligne de cache pointée par les bits de l'algorithme.png|vignette|Ligne de cache pointée par les bits de l'algorithme.]] |} ===LRU amélioré=== L'algorithme LRU, ainsi que ses variantes approximatives, sont très efficaces tant que le programme respecte relativement bien la localité temporelle. Par contre, Le LRU se comporte assez mal dans les circonstances ou la localité temporelle est mauvaise mais où la localité spatiale est respectée, le cas le plus emblématique étant le parcours d'un tableau. Pour résoudre ce problème, des variantes du LRU existent. Une variante très connue, l''''algorithme 2Q''', utilise deux caches : un cache FIFO pour les données accédées une seule fois et un second cache LRU. Évidemment, les données lues une seconde fois sont migrées du cache FIFO vers le cache LRU, ce qui n'est pas très pratique. Les processeurs n'utilisent donc pas cette technique, mais celle-ci est utilisée dans les caches de disque dur. D'autres variantes du LRU combinent plusieurs algorithmes à la fois et vont choisir lequel de ces algorithmes est le plus adapté à la situation. Notre cache pourra ainsi détecter s’il vaut mieux utiliser du MRU, du LRU, ou du LFU suivant la situation. ==Les écritures dans le cache : gestion et optimisations== Les écritures se font à une adresse mémoire bien précise, qui peut ou non être chargée dans le cache. Si la donnée à écrire est chargée dans le cache, elle est modifiée directement dans le cache, mais elle ne l'est pas forcément en mémoire RAM. Suivant le processeur, les écritures sont ou non propagées en mémoire RAM. Il existe deux stratégies d'écritures, appelées respectivement le ''write-back'' et le ''write-through''. Avec un cache ''write-back'', si la donnée à mettre à jour est présente dans le cache, on écrit dans celui-ci sans écrire dans la mémoire RAM. Dans ces conditions, une donnée n'est enregistrée en mémoire que si celle-ci quitte le cache, ce qui évite de nombreuses écritures mémoires inutiles. [[File:Cache write-through.png|centre|vignette|upright=2|Cache write-through.]] Avec les caches '''Write-Through''', toute écriture dans le cache est propagée en RAM. Cette stratégie augmente le nombre d'écritures dans la mémoire RAM, ce qui peut saturer le bus reliant le processeur à la mémoire. Les performances de ces caches sont donc légèrement moins bonnes que pour les caches ''write back''. Par contre, ils sont utiles dans les architectures avec plusieurs processeurs, comme nous le verrons dans les chapitres sur les architectures multiprocesseurs. [[File:Cache write-back.png|centre|vignette|upright=2|Cache write-back.]] ===Les caches ''Write-through''=== Sans optimisation particulière, on ne peut écrire dans un cache ''write-through'' pendant qu'une écriture en RAM a lieu en même temps : cela forcerait à effectuer deux écritures simultanées, en comptant celle imposée par l'écriture dans le cache. Pour éviter cela, certains caches ''write-through'' intègrent un '''tampon d’écriture''', qui sert de file d'attente pour les écritures en RAM. C'est une mémoire FIFO dans laquelle on place temporairement les données à écrire en RAM, où elles attendent en attendant que la RAM soit libre. Grâce à lui, le processeur peut écrire dans un cache même si d'autres écritures sont en attente dans le tampon d'écriture. Par souci d'efficacité, des écritures à la même adresse en attente dans le tampon d’écriture sont fusionnées en une seule. Cela fait un peu de place dans le tampon d’écriture, et lui permet d'accumuler plus d'écritures avant de devoir bloquer le cache. Il est aussi possible de fusionner des écritures à adresses consécutives de la mémoire en une seule écriture en rafales. Dans les deux cas, on parle de '''combinaison d'écriture'''. Mais la technique du tampon d'écriture a cependant un léger défaut qui se manifeste dans une situation bien précise : quand le processeur veut lire une donnée en attente dans le tampon d’écriture. La première manière de gérer cette situation est de mettre en attente la lecture tant que la donnée n'a pas été écrite en mémoire RAM. On peut aussi lire la donnée directement dans le tampon d'écriture, cette optimisation portant le nom de '''''store-to-load forwading'''''. Dans tous les cas, il faut détecter le cas où une lecture accède à une donnée dans le tampon d'écriture. À chaque lecture, l'adresse à lire est envoyée au tampon d'écriture, qui vérifie si une écriture en attente se fait à cette adresse. Pour cela, le tampon d’écriture doit être un cache, dont chaque entrée mémorise une écriture. Chaque ligne de cache contient la donnée à écrire, et le tag de la ligne de cache contient l'adresse où écrire la donnée. Notons que cache d'écriture a une politique de remplacement de type FIFO, le tampon d'écriture non-optimisé étant une mémoire FIFO. ===Les caches ''Write-back''=== Les caches ''write-back'' ont beau avoir des performances supérieures à celles des caches ''write-through'', il existe des optimisations qui permettent d'améliorer leurs performances. Ces optimisations consistent à ajouter des caches spécialisés à côté du cache proprement dit. Ces caches permettent de mémoriser des données qui sont éliminées du cache par les algorithmes de remplacement de ligne cache, sans pour autant faire une écriture en RAM. En suivant la procédure habituelle de remplacement des lignes de cache, on doit rapatrier la ligne en RAM avant d'en charger une nouvelle. On peut améliorer la situation en faisant l'inverse : on charge la nouvelle ligne pendant que l'ancienne donnée est rapatriée en RAM. Ainsi, la nouvelle ligne est disponible plus tôt pour le processeur, diminuant son temps d'attente. Pour implémenter cette technique, on doit mémoriser l'ancienne ligne de cache temporairement dans un '''cache d’éviction''' (ou ''write-back buffer''). [[File:Cache d’éviction.png|centre|vignette|upright=2|Cache d’éviction]] Les caches directement adressés ou associatifs par voie possèdent aussi un tampon d’écriture amélioré. Pour limiter les défauts par conflit de ces caches, des scientifiques ont eu l'idée d'insérer un cache pour stocker les données virées du cache. En faisant ainsi, si une donnée est virée du cache, on peut alors la retrouver dans ce cache spécialisé. Ce cache s'appelle le '''cache de victime'''. Ce cache de victime est géré par un algorithme de suppression des lignes de cache de type FIFO. Petit détail : ce cache utilise un tag légèrement plus long que celui du cache directement adressé au-dessus de lui. L'index de la ligne de cache doit en effet être contenu dans le tag du cache de victime, pour bien distinguer deux adresses différentes, qui iraient dans la même ligne du cache juste au-dessus. [[File:Victim Cache Implementation Example.svg|centre|vignette|upright=1|Cache de victime.]] ===La configuration du fonctionnement du cache=== Sur de nombreux processeurs, il est possible de configurer la mémoire cache pour qu'elle fonctionne soit en mode ''write-back'', soit en mode ''write-through''. Pour cela, les processeurs modernes incorporent des '''registres de configuration du cache'''. Le terme ''registre de configuration du cache'' est assez transparent et indique bien quel est leur rôle. Ils configurent comment le cache est utilisé et permettent notamment de configurer le cache pour dire s'il doit fonctionner en mode ''write-back'' ou ''write-through''. Ils permettent aussi d'activer ou de désactiver la combinaison sur écriture. Les registres en question sont configurés soit par le BIOS, soit par le système d'exploitation. Ce sont des registres protégés, que les applications ne peuvent pas configurer, elles n'en ont pas le droit. Typiquement, ils ne sont accessibles en écriture qu'en mode noyau. Sur les processeurs x86, les registres de configuration du cache sont appelés des '''''Memory type range registers''''' (''MTRRs''). Les MTRRs sont assez nombreux, et il y a notamment une différence entre mode réel et protégé. Si vous vous souvenez des chapitres sur le mode d'adressage et la mémoire virtuelle, vous vous souvenez que les processeurs x86 incorporent plusieurs modes de fonctionnement. En mode réel, le processeur ne peut adresser qu'un mébioctet de RAM, avec un système de segmentation particulier. En mode protégé, le processeur peut adresser toute la mémoire et la segmentation fonctionne différemment, quand elle n'est pas simplement désactivée. Les MTRRs sont séparés en deux : ceux pour le mode réel, ceux pour le mode protégé. Les MTRRs fixes sont ceux qui configurent le cache en mode réel, ils étaient utilisés pour gérer l'accès au BIOS, à la mémoire VGA de la carte graphique, et quelques autres accès aux entrées-sorties basiques gérées nativement par le BIOS. Pour le mode protégé, les processeurs au-delà du 386 incorporent des MTRRs variables, qui servent pour les autres entrées-sorties en général, notamment les périphériques PCI, la mémoire vidéo de la carte graphique, et j'en passe. De nos jours, les registres de configuration du cache sont désuets et cette fonctionnalité est gérée directement par la mémoire virtuelle. La table des pages contient, pour chaque page mémoire, des bits de contrôle qui disent si la page mémoire est cacheable ou non. Le contournement de cache est alors géré par le système de mémoire virtuelle, le cache de TLB et tout ce qui va avec. ===L’allocation sur écriture=== Que faire quand une écriture modifie une donnée qui n'est pas dans le cache ? Doit-on écrire la donnée dans le cache, ou non ? Si la donnée est écrite dans le cache, on dit que le cache fait une '''allocation sur l'écriture''' (ou ''write-allocate''). Certains caches effectuent une telle allocation sur écriture, mais d'autres ne le font pas ou du moins pas systématiquement. L’allocation sur écriture peut se décliner en deux sous-catégories : le '''chargement à la demande''' et l''''écriture immédiate'''. Dans le premier cas, on charge la donnée à modifier dans le cache, et on la remplace avec la donnée écrite. Dans l'écriture immédiate, l'écriture a lieu directement dans le cache et la donnée à modifier n'est pas chargée dans le cache. Évidemment, seule une portion de la ligne de cache contient la donnée écrite (valide), et le reste contient des données invalides. Le cache doit savoir quelles sont les portions du cache qui sont valides : cela demande d'utiliser un ''sector cache''. [[File:Write-back with write-allocation.svg|centre|vignette|upright=2|Cache Write-back avec allocation sur écriture.]] Sans allocation sur écriture, l'écriture est transférée directement aux niveaux de cache inférieurs ou à la mémoire si la donnée à modifier n'est pas dans le cache. Certains caches de ce genre utilisent une petite optimisation : lors de toute écriture, ils supposent que l'écriture donnera un succès de cache. Si c'est le cas, la ligne de cache qui contient la donnée est mise à jour avec la donnée à écrire. Mais si ce n'est pas le cas, la ligne de cache est invalidée, et l'écriture est transférée directement à la mémoire ou aux niveaux de cache inférieurs. [[File:Write-through with no-write-allocation.svg|centre|vignette|upright=2|Cache Write-through sans allocation sur écriture.]] ===La cohérence des caches=== Il arrive parfois que la mémoire d'un ordinateur soit mise à jour, sans que les modifications soient répercutées dans les mémoires cache. Dans ce cas, le cache contient une donnée périmée. Or, un processeur doit toujours éviter de se retrouver avec une donnée périmée et doit toujours avoir la valeur correcte dans ses caches : cela s'appelle la '''cohérence des caches'''. Il est possible de se retrouver avec des valeurs périmées dans le cache sur les ordinateurs avec plusieurs processeurs, ou si un périphérique écrit en RAM, les modifications ne sont pas répercutées automatiquement dans les mémoires cache. Pour résoudre ce problème, on peut interdire de charger dans le cache des données stockées dans les zones de la mémoire dédiées aux périphériques. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires cache. Autre solution : utiliser le fait que les périphériques déclenchent une interruption matérielle pour laisser le contrôleur DMA accéder à la mémoire. Dans ce cas, il suffit de vider les caches à chaque interruption matérielle. Le processeur peut le faire automatiquement, ou fournir des instructions pour. ==Le ''cache bypassing'' : contourner le cache== Dans certaines situations, le cache n'est pas utilisé pour certains accès mémoire. Diverses techniques permettent en effet d'effectuer des accès mémoire qui contournent le cache, qui ne passent pas par le cache. Ils sont utilisés quand l'accès en cache fait que des instructions normales ne fonctionnent pas. Par exemple, de tels accès directs à la RAM sont notamment utilisés pour l'implémentation d'instructions atomiques, une classe d'instructions spécifiques utilisées sur les processeurs multicœurs, dont nous parlerons dans plusieurs chapitres. Mais ils sont aussi utilisés pour l'accès aux périphériques, ce que nous allons voir maintenant. ===Accéder aux périphériques demande de contourner le cache=== Pour rappel, un périphérique (au sens d'entrée-sortie) contient des registres d’interfaçage qui ont une adresse au même titre que les cases mémoire. Un périphérique peut à tout instant modifier ses registres d’interfaçage, ce qui se répercute automatiquement dans l'espace d'adressage, mais rien de tout cela n'est transmis au cache. Si les accès aux périphériques passaient par l'intermédiaire du cache, on aurait droit à des problèmes. On aurait encore une fois droit à des problèmes de cohérence des caches. Le problème est géré différemment suivant que l'on utilise un espace d'adressage séparé ou des entrées-sorties mappées en mémoire. La solution est que les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache. Cela demande d'adapter le cache et le processeur. L'implémentation exacte dépend de comment sont adressés les périphériques. Pour rappel, il y a deux solutions pour adresser les périphériques : soit les périphériques disposent d'un espace d'adressage séparé de celui de la mémoire, soit il y un espace d'adressage unique partagé entre processeur et mémoire. Les deux cas donnent des solutions différentes. Avec un espace d'adressage séparé, l'espace d'adressage des périphériques n'est pas caché : aucun accès dans cet espace d'adressage ne passe par le cache. La mémoire cache n'est utilisée que pour l'espace d'adressage des mémoires, rien d'autre. C'est de loin le cas le plus simple : il suffit de concevoir le processeur pour. Il dispose d'instructions séparées pour les accès aux registres d’interfaçage et à la RAM/ROM, les premières ne passent pas par le cache, les autres si. Avec des entrées-sorties mappées en mémoire, la même solution est utilisée, mais dans une version un peu différente. Là encore, les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache, si on veut qu'ils marchent comme ils le doivent. Cela demande d'adapter le cache et le matériel pour que accès aux périphériques mappés en mémoire contournent le cache. Des adresses, voire des zones entières de la mémoire, sont marquées comme étant non-cachables. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires caches. Là encore, le processeur doit être prévu pour : on doit pouvoir le configurer de manière à marquer certaines zones de la RAM comme non-cacheable. Reste qu'il faut marquer des régions de la RAM comme non-cacheable. Pour cela, on améliore les registres de configuration du cache, vus plus haut, afin qu'ils permettent de configurer certaines portions de la RAM pour préciser qu'elles ne doivent pas être mises en cache, qu'il faut activer le contournement de cache pour celles-ci. ===Contourner le cache pour des raisons de performance=== Il arrive que des données avec une faible localité soient chargées dans le cache inutilement. Or, il vaut mieux que ces données transitent directement entre le processeur et la mémoire, sans passer par l'intermédiaire du cache. Pour cela, le processeur peut fournir des instructions d'accès mémoire qui ne passent pas par le cache, à côté d'instructions normales. De telle instructions sont appelées des '''instructions mémoire non-temporelles'''. Non-temporelle, dans le sens : pas de localité temporelle (c.a.d que les données ne seront pas réutilisées plus tard). Mais il existe aussi des techniques matérielles, où le cache détecte à l'exécution les lectures qui gagnent à contourner le cache. La dernière méthode demande d'identifier les instructions à l'origine des défauts de cache, le processeur accédant directement à la RAM quand une telle instruction est détectée. Si une instruction d'accès mémoire fait trop de défauts de cache, c'est signe qu'elle gagne à contourner le cache. L'idée est de mémoriser, pour chaque instruction d'accès mémoire, un historique de ses défauts de cache. Il existe plusieurs méthodes pour cela, mais toutes demandent d'ajouter de quoi mémoriser l'historique des défauts de cache des instructions. L'historique est mémorisé dans une mémoire appelée la '''table d’historique des défauts de lecture''' (''load miss history table''), qui est souvent un cache. L'historique en question est, dans sa version la plus simple, un compteur de quelques bits incrémenté à chaque succès de cache et décrémenté à chaque défaut de cache, qui indique si l'instruction a en moyenne fait plus de défauts ou de succès de cache. La table associe le ''program counter'' d'une instruction mémoire à cet historique. À la première exécution d'une instruction d'accès mémoire, une entrée de cette table est réservée pour l'instruction. Lors des accès ultérieurs, le processeur récupérer les informations associées et décide s'il faut contourner le cache ou non. ==La hiérarchie mémoire des caches== [[File:Cache Hierarchy.png|vignette|Hiérarchie de caches]] On pourrait croire qu'un seul cache est largement suffisant pour compenser la lenteur de la mémoire. Hélas, les processeurs sont devenus tellement rapides que les caches sont eux-mêmes très lents ! Pour rappel, plus une mémoire peut contenir de données, plus elle est lente. Et les caches ne sont pas épargnés. Si on devait utiliser un seul cache, celui-ci serait très gros et donc trop lent. La situation qu'on cherche à éviter avec la mémoire RAM revient de plus belle. Même problème, même solution : si on a décidé de diviser la mémoire principale en plusieurs mémoires de taille et de vitesse différentes, on peut bien faire la même chose avec la mémoire cache. Depuis environ une vingtaine d'années, un processeur contient plusieurs caches de capacités très différentes : les caches L1, L2 et parfois un cache L3. Certains de ces caches sont petits, mais très rapides : c'est ceux auxquels on va accéder en priorité. Viennent ensuite d'autres caches, de taille variable, mais plus lents. Les processeurs ont donc une hiérarchie de caches qui se fait de plus en plus complexe avec le temps. Cette hiérarchie est composée de plusieurs niveaux de cache, qui vont des niveaux inférieurs proches de la mémoire RAM à des niveaux supérieurs proches du processeur. Plus on monte vers les niveaux supérieurs, plus les caches sont petits et rapides. Un accès mémoire dans une hiérarchie de cache fonctionne comme suit : on commence par vérifier si la donnée recherchée est dans le cache le plus rapide, à savoir le cache L1. Si c'est le cas,n on la charge depuis ce cache directement. Si elle n’y est pas, on vérifie si elle est dans le cache de niveau supérieur, le cache L2. Et rebelote ! Si elle n'y est pas, on vérifie le cache du niveau supérieur. Et on répète cette opération, jusqu’à avoir vérifié tous les caches. Si la donnée n'est dans aucun cache, on doit alors aller chercher la donnée en mémoire. [[File:Hiérarchie de caches.png|centre|vignette|upright=2|Hiérarchie de caches]] Il y a des différences assez notables entre chaque niveau de cache. Par exemple, les différents niveaux de cache n'ont pas forcément les mêmes politiques de remplacement des lignes de cache. Le cache L1 a généralement une politique de remplacement simple, très rapide, mais peu efficace. De même, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Le cache le plus proche de la mémoire est appelé le '''cache de dernier niveau''', ''Last Level Cache'' en anglais. Il a parfois des caractéristiques totalement différentes des autres caches. Par exemple, sur les processeurs multicoeurs, le cache L3 n'est techniquement pas dans le cœur, mais fait partie d'un ensemble de circuits reliés, comme le contrôleur mémoire ou l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc. ===Les caches exclusifs et inclusifs=== Notons que du point de vue de cette vérification, il faut distinguer les caches inclusifs et exclusifs. Avec les caches inclusifs, si une donnée est présente dans un cache, alors elle est présente dans les caches des niveaux inférieurs, ce qui implique l'existence de données en doublon dans plusieurs niveaux de cache. À l'opposé, les caches exclusifs font que toute donnée est présente dans un seul cache, pas les autres. Il existe aussi des caches qui ne sont ni inclusifs, ni exclusifs. Sur ces caches, chaque niveau de cache gère lui-même ses données, sans se préoccuper du contenu des autres caches. Pas besoin de mettre à jour les niveaux de cache antérieurs en cas de mise à jour de son contenu, ou en cas d'éviction d'une ligne de cache. La conception de tels caches est bien plus simple. Dans les '''caches exclusifs''', le contenu d'un cache n'est pas recopié dans le cache de niveau inférieur. Il n'y a pas de donnée en double et on utilise 100 % de la capacité du cache, ce qui améliore le taux de succès. Par contre, le temps d'accès est un peu plus long. La raison est que si une donnée n'est pas dans le cache L1, on doit vérifier l'intégralité du cache L2, puis du cache L3. De plus, assurer qu'une donnée n'est présente que dans un seul cache nécessite aux différents niveaux de caches de communiquer entre eux pour garantir que l'on a pas de copies en trop d'une ligne de cache, ce qui peut prendre du temps. [[File:Caches exclusifs.png|centre|vignette|upright=2|Caches exclusifs]] Dans le cas des '''caches inclusifs''', le contenu d'un cache est recopié dans les caches de niveau inférieur. Par exemple, le cache L1 est recopié dans le cache L2 et éventuellement dans le cache L3. Ce genre de cache a un avantage : le temps d'accès à une donnée est plus faible. La raison est qu'il ne faut pas vérifier tout un cache, mais seulement la partie qui ne contient pas de donnée en doublon. Par exemple, si la donnée voulue n'est pas dans le cache L1, on n'est pas obligé de vérifier la partie du cache L2 qui contient la copie du L1. Ainsi, pas besoin de vérifier certaines portions du cache, ce qui est plus rapide et permet de simplifier les circuits de vérification. En contrepartie, l'inclusion fait que qu'une partie du cache contient des copies inutiles, comme si le cache était plus petit. De plus, maintenir l'inclusion est compliqué et demande des circuits en plus et/ou des échanges de données entre caches. [[File:Caches inclusifs.png|centre|vignette|upright=2|Caches inclusifs]] Maintenir l'inclusion demande de respecter des contraintes assez fortes, ce qui ne se fait pas facilement. Premièrement, toute donnée chargée dans un cache doit aussi l'être dans les caches de niveau inférieur. Ensuite, quand une donnée est présente dans un cache, elle doit être maintenue dans les niveaux de cache inférieurs. De plus, toute donnée effacée d'un cache doit être effacée des niveaux de cache supérieurs : si une donnée quitte le cache L2, elle doit être effacée du L1. Ces trois contraintes posent des problèmes si chaque cache décide du remplacement des lignes de cache en utilisant un algorithme comme LRU, LFU, MRU, ou autre, qui utilise l'historique des accès. En effet, dans ce cas, le cache décide de remplacer les lignes de cache selon l'historique des accès, historique qui varie suivant chaque niveau de cache. Par exemple, une donnée rarement utilisée dans le L2 peut parfaitement être très fréquemment utilisée dans le L1 : la donnée sera alors remplacée dans le L2, mais sera maintenue dans le L1. On observe aussi des problèmes quand il existe plusieurs caches à un seul niveau : chaque cache peut remplacer les lignes de cache d'une manière indépendante des autres caches du même niveau, donnant lieu au même type de problème. Pour maintenir l'inclusion, les caches doivent se transmettre des informations qui permettent de maintenir l'inclusion. Par exemple, les caches de niveaux inférieurs doivent prévenir les niveaux de cache supérieurs quand ils remplacent une ligne de cache. De plus, toute mise à jour dans un cache doit être répercutée dans les niveaux de cache inférieurs et/ou supérieurs. On doit donc transférer des informations de mise à jour entre les différents niveaux de cache. Généralement, le contenu des caches d'instruction n'est pas inclus dans les caches de niveau inférieurs, afin d'éviter que les instructions et les données se marchent sur les pieds. Enfin, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Dans ce cas, l'inclusion est plus difficile à maintenir, pour des raisons assez techniques. ===Les caches eDRAM, sur la carte mère et autres=== D'ordinaire, les mémoires caches sont intégrées au processeur, à savoir que cache et CPU sont dans le même circuit imprimé. Les caches sont donc fabriqués avec de la SRAM, seule forme de mémoire qu'on peut implémenter dans un circuit intégré. Intégrer tous les caches dans le processeur est une solution et efficace. Mais certains processeurs ont procédé autrement. [[File:Cache-on-a-stick module.jpg|vignette|Cache-on-a-stick module]] Des processeurs assez anciens incorporaient un cache L1 dans le processeur, mais plaçaient un cache L2 sur la carte mère. Le cache était clippé sur un connecteur sur la carte mère, un peu comme le sont les barrettes de mémoire. On parlait alors de '''''Cache on a stick''''' (COAST). On aurait pu s'attendre à ce que de tels caches soient en DRAM, vu qu'ils sont placés sur des barrettes de RAM, mais la ressemblance avec la mémoire RAM principale s'arrête là. Le cache était fabriqué en mémoire SRAM, même s'il est en théorie possible de faire de tels caches avec de la DRAM. Les premiers processeurs avec un cache faisaient ainsi, au début des années 90. Il a été introduiot sur les processeurs Motorola, et a été utilisé sur les IBM PC et les Macintosh de l'époque. Les ordinateurs Macintosh utilisaient de tels caches, pour la pluaprt des modèles. Pour ce qui est des PC, les premiers processeurs x86 faisaient pareil, notamment les processeurs Intel. Le 486, le Pentium et le Pentium 2 utilisaient des ''Cache on a stick''. L'avantage est que cela permettait de mettre plus de cache, à une époque où les circuits étaient limités en transistors. De plus, cela permettait au consommateur de choisir quelle quantité de cache il voulait, selon ses finances. Il était possible de laisser le processeur fonctionner soit sans mémoire cache, soit avec un cache de 256 Kibioctets, de 512 Kibioctets, etc. Il était possible d'upgrader le cache si besoin. Pour les CPU Intel, le cache était connecté sur le bus système, au même titre que la mémoire RAM et les entrées-sorties. Il faut dire que les processeurs de l'époque utilisaient un bus système et n'avaient pas de bus mémoire dédié. Mais en théorie, rien n’empêche de connecter le cache sur un bus mémoire dédié. Toujours est-il que les lectures et écritures étaient propagées à la fois dans le cache et la RAM. Les écritures se faisaient dans les deux, systématiquement dans la RAM, mais aussi dans le cache en cas de succès de cache. Les lectures étaient servies soit par le cache en cas de succès de cache, soit par la RAM en cas de défaut de cache. Si le cache répondait en premier, la transaction sur le bus se terminait précocement et l'accès en RAM était abandonné. [[File:Intel486 Иерархия памяти.png|centre|vignette|upright=2.5|Intel486 : le cache était connecté sur le bus système.]] À l'inverse, certains processeurs possédaient un cache fabriqué en mémoire DRAM, et plus précisément avec de la mémoire eDRAM. Le cache n'était pas intégré dans le même circuit imprimé que le processeur, mais profitait d'une architecture en ''chiplet''. Pour rappel, cela veut dire que le processeur est en réalité composé de plusieurs circuits intégré séparés, mais interconnectés et soudés sur un même PCB carré. Avec un cache en eDRAM, le cache avait son propre circuit intégré, séparé du circuit intégré du processeur ou du circuit intégré pour le contrôleur mémoire/IO. Un exemple est celui du cache des processeurs Intel de microarchitecture Broadwell, vus dans ce chapitre dans la section sur les caches splittés. Les tags étaient intégrés dans le circuit intégré du processeur, mais les données étaient mémorisées dans une puce d'eDRAM séparée. La puce eDRAM correspondait en réalité à une DRAM adressable qui servait de DRAM pour les données et mémorisaient les voies du cache. ==Les caches adressés par somme et hashés== Les caches adressés par somme sont optimisés pour incorporer certains calculs d'adresse directement dans le cache lui-même. Pour rappel, certains modes d'adressage impliquent un calcul d'adresse, qui ajoute une constante à une adresse de base. Généralement, l'adresse de base est l'adresse d'un tableau ou d'une structure, et la constante ajoutée indique la position de la donnée dans le tableau/la structure. Les caches hashés et les caches adressés par somme permettent de faire l'addition directement dans la mémoire cache. Voyons d'abord les caches hashés, avant de passer aux caches adressés par somme. Sur les '''caches hashés''', l'addition est remplacée par une autre opération, par exemple des opérations bit à bit du style XOR, AND ou OR, etc. Seulement, utiliser des opérations bit à bit pose un problème : il arrive que deux couples Adresse/décalage donnent le même résultat. Par exemple, le couple Adresse/décalage 11101111/0001 donnera la même adresse que le couple 11110000/0000. Dit autrement, deux adresses censées être différentes (après application du décalage) sont en réalité attribuées à la même ligne de cache. Il est toutefois possible de gérer ces situations, mais cela demande des astuces de haute volée pour faire fonctionner la mémoire cache correctement. Sur les '''caches adressés par somme''', le décodeur est modifié pour se passer de l'addition. Pour comprendre comment, il faut rappeler qu'un décodeur normal est composé de comparateurs, qui vérifient si l'entrée est égale à une constante bien précise. Sur un cache ordinaire, l'addition est faite séparément du décodage des adresses par le cache, dans l'unité de calcul ou dans l'unité de génération d'adresse. [[File:Non sum adressed cache.png|centre|vignette|upright=2|Cache normal.]] Mais les caches adressés par somme modifient le décodeur, qui est alors composé de comparateurs qui testent si la somme adresse + décalage est égale à une constante. [[File:Cache adressé par somme.png|centre|vignette|upright=2|Cache adressé par somme.]] Chaque circuit du décodeur fait le test suivant, avec K une constante qui dépend du circuit : : <math>A + B = K</math> Ce qui est équivalent à faire le test suivant : : <math>A + B - K = 0</math> En complément à deux, on a <math>- K = \overline{K} + 1</math>. En injectant dans l'équation précédente, on a : : <math>A + B + \overline{K} + 1 = 0</math> En réorganisant les termes, on a : : <math>A + B + \overline{K} = - 1</math> Il suffit d'utiliser un additionneur ''carry-save'' pour faire l'addition des trois termes. Rappelons qu'un tel additionneur fournit deux résultats en sortie : une somme calculée sans propager les retenues et les retenues en question. Notons que les retenues sont à décaler d'un cran, vu qu'elles sont censées s'appliquer à la colonne suivante. En notant la somme S et les retenues R, on a: : <math>S + (R << 1) = - 1 </math>, le décalage d'un cran à gauche étant noté <math><< 1</math>. Ensuite, -1 est codé avec un nombre dont tous les bits sont à 1 en complément à un/deux. : <math>S + (R << 1) = 111 \cdots 111111</math> [[File:Sum + retenue add.png|centre|vignette|upright=2|Sum + retenue add]] Un simple raisonnement nous permet de savoir si le résultat est bien -1, sans faire l'addition <math>S + (R << 1)</math>. En effet, on ne peut obtenir -1 que si la somme est l'inverse des retenues : un 0 dans le premier nombre correspond à un 1 dans l'autre, et réciproquement. En clair, on doit avoir <math>\overline{S} = R << 1</math>. Pour vérifier cela, il suffit de faire un simple XOR entre la somme et les retenues décalées d'un cran. On a alors : : <math>S \oplus (R << 1) = 111 \cdots 111111</math> La comparaison avec -1 se fait avec une porte ET à plusieurs entrées. En effet, la porte donnera un 1 seulement si tous les bits d'entrée sont à 1, ce qui est ce qu'on veut tester. Au final, l'additionneur pour l'addition adresse + décalage est remplacé par un additionneur carry-save suivi d'une couche de portes XOR et d'un comparateur avec une constante, ce qui économise de circuits et améliore les performances. [[File:Final circuit of sum addressed cache.png|centre|vignette|upright=2|Cache adressé par somme.]] En prenant en compte que la constante K est justement une constante, certaines entrées de l'additionneur carry-save sont toujours à 0 ou à 1, ce qui permet quelques simplifications à grand coup d’algèbre de Boole. Chaque additionneur complet qui compose l’additionneur carry-save est remplacée par des demi-additionneurs (ou par un circuit similaire). Autant dire que l'on gagne tout de même un petit peu en rapidité, en supprimant une couche de portes logiques. Le circuit de décodage économise aussi des portes logiques, ce qui est appréciable. ==Les caches à accès uniforme et non-uniforme== Intuitivement, le temps d'accès au cache est le même pour toutes les lignes de cache. Il s'agit de cache appelés '''caches à accès uniforme''', sous-entendu à temps d'accès uniforme. Mais sur les caches de grande capacité, il arrive souvent que le temps de propagation des signaux varie fortement suivant la ligne de cache à lire. D'ordinaire, on se cale sur la ligne de cache la plus lente pour caler la fréquence d'horloge du cache, même si on pourrait faire mieux. Cependant, les '''caches à accès non uniforme''' ont une latence différente pour chaque ligne d'un même cache. Certaines lignes de cache sont plus rapides que d'autres. Niveau terminologie, nous allons parler de caches UCA et NUCA : ''Uniform Access Cache'' pour les caches à accès uniforme, ''Non-Uniform Access Cache'' pour les caches à accès non-uniforme. [[File:Caches UCA et NUCA.png|vignette|Caches UCA et NUCA.]] Les caches NUCA et UCA sont souvent composés de plusieurs banques séparées, typiquement une par voie. Sur les caches UCA, les banques sont interconnectées avec le processeur de manière à ce que toutes les interconnexions ont la même longueur pour toutes les banques. Typiquement, les banques sont organisées en carré, avec les interconnexions qui partent du centre, avec une disposition en H, illustrée ci-contre Mais avec les caches NUCA, ce n'est pas le cas. Les interconnexions sont simplifiées et ont des longueurs différentes. Les caches NUCA n'ont pas tous le même genre d'interconnexions, qui dépendent du cache NUCA. En général, les interconnexion forme un réseau avec des sortes de routeurs qui redirigent les données/commandes vers la bonne destination : cache ou processeur. Les banques plus proches du processeur sont accessibles plus rapidement que celles éloignées, même si la différence n'est pas énorme. Les caches NUCA sont généralement associatifs par voie. Les plus simples utilisent une banque par voie pour le cache, ce qui fait que certaines voies répondent plus vite que les autres. La détection des succès de cache est alors plus rapide si la donnée lue/écrite est dans une voie/banque rapide. En théorie, les défauts de cache demandent de vérifier toutes les banques, et se calent donc sur la pire latence. Mais divers caches se débrouillent pour que ce ne soit pas le cas, soit en vérifiant les banquyes unes par une, soit par un mécanisme de recherche plus complexe. Les caches NUCA sont surtout utilisés pour les caches L3 et L4, éventuellement les caches L2. Les caches L1 sont systématiquement des caches UCA, car la latence de l'accès au cache L1 est utilisée par le processeur pour décider quand lancer les instructions. Pour simplifier, le processeur peut démarrer en avance une instruction avant qu'une opérande soit lue dans le cache L1, de manière à ce que la donnée arrive en entrée de l'ALU pile en même temps que l'instruction. Une histoire d'exécution dans le désordre et d'émission anticipée des instructions qu'on détaillera dans une bonne dizaine de chapitres. Toujours est-il que tout est plus simple pour le processeur si le cache L1 a un temps d'accès fixe. Par contre, les caches L3 et L4 sont traités en attendant que les données arrivent, le processeur reprend l'exécution des instructions quand les caches L3 et L4 ont terminé de répondre, pas avant. Avec l'association une banque = une voie, la correspondance ligne de cache → bloc de mémoire qui est statique : on ne peut pas déplacer le contenu d'une ligne de cache dans une autre portion de mémoire plus rapide suivant les besoins. Mais la recherche académique a étudié le cas où la correspondance entre une ligne de cache et une banque varie à l’exécution. Pour nommer cette distinction, on parle de caches S-NUCA (''Static NUCA'') et D-NUCA (''Dynamic NUCA''). Intuitivement, on s'attend à ce que les caches D-NUCA soient plus performants que les caches S-NUCA. Les lignes de cache les plus utilisées peuvent migrer dans une banque rapide, alors que les lignes de cache moins utilisées vont dans une banque éloignée. Les lignes de cache se répartissent dans le cache dynamiquement dans les banques où elles sont le plus adaptées. Mais paradoxalement, le gain des caches D-NUCA est presque nul, voire insignifiant. La raison est que les caches D-NUCA doivent incorporer un système pour déterminer dans quelle banque se situe la donnée pour détecter les succès/défauts de cache, ainsi qu'un système pour migrer les données entre banques. Et ce système augmente le temps d'accès au cache, réduisant à néant l'intérêt d'un cache D-NUCA. Si on économise quelques microsecondes de temps d'accès en passant d'un cache UCA à un cache S-NUCA, ce n'est pas pour les perdre en passant à un D-NUCA. La majorité des caches D-NUCA sont donc en cours de recherche, mais ne sont pas utilisés en pratique. ==La tolérance aux erreurs des caches== Une mémoire cache reste avant tout une mémoire RAM, bien que ce soit de la SRAM. Elle n'est pas parfaite et est donc sujette à des erreurs, qui peuvent inverser un bit ou l'effacer. De telles erreurs sont liées à des rayons cosmiques très énergétiques, à des particules alpha produites par le packaging ou le métal deu circuit intégré, peu importe : l'essentiel est qu'ils inversent parfois un bit. Les mémoires modernes savent se protéger contre de telles erreurs, en utilisant trois moyens. ===Les mémoires caches ECC et à bit de parité=== Le premier moyen est l'usage de codes correcteurs d'erreurs, qui ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Les bits ajoutés dépendent de la donnée mémorisée dans le byte, et servent à détecter une erreur, éventuellement à la corriger. Le cas le plus simple ajoute un simple bit de parité pour chaque byte et se contente de détecter les erreurs dans les corriger. Les autres codes ECC permettent eux de corriger des erreurs, mais ils demandent d'ajouter au moins deux bits par byte, ce qui a un cout en circuit plus élevé. Un simple bit de parité permet de détecter qu'un bit a été inversé, mais ne permet pas de corriger l'erreur. En soi, ce n'est pas un problème. Si une erreur est détectée, on considère que la ligne de cache est invalide. Le cache gère la situation comme un défaut de cache et va chercher la donnée valide en mémoire RAM. Le cout en circuits est donc faible, mais les défauts de cache sont plus nombreux. Les codes ECC sont eux capables de corriger les erreurs, si elles ne modifient pas trop de bits d'un coup. Par contre, ils utilisent deux à trois bits par octet, ce qui a un cout en circuits loin d'être négligeable. Il y a donc un compromis entre défauts de cache et cout en circuits. La gestion de l'ECC est différente suivant le niveau de cache. Généralement, le cache L1 n'utilise pas l'ECC mais se contente d'un simple bit de parité pour éviter la corruption de ses données. Le cache étant petit, les corruptions de données sont assez rares, et les défauts de cache induits faibles. Il est plus important d'utiliser un code de détection d'erreur simple, rapide, qui ne ralentit pas le cache et n'augmente pas sa latence. Si une ligne de cache est corrompue, il a juste à aller lire la ligne depuis le cache L2, ou un niveau de cache inférieur. Du moins, c'est possible sur le cache en question est un cache inclusif et/ou ''write-through''. Par contre, le niveau de cache L2 et ceux en-dessous utilisent presque systématiquement une mémoire SRAM ECC. La raison principale étant que ce sont des caches assez gros, pour lesquels la probabilité d'une erreur est assez élevée. Plus une mémoire a de bits et prend de la place, plus il y a une chance élevée qu'un bit s'inverse. Et vu que les caches L2/L3/L4 sont par nature plus lents et plus gros, ils peuvent se permettre le cout en performance lié à l'ECC, idem pour le cout en circuit. Sans compter qu'en cas d'erreur, ils doivent aller lire la ligne de cache originelle en mémoire RAM, ce qui est très lent ! Mieux vaut corriger l'erreur sur place en utilisant l'ECC. ===L'usage du ''memory scrubbing'' sur les caches=== La plupart des erreurs ne changent qu'un seul bit dans un byte, mais le problème est que ces erreurs s'accumulent. Entre deux accès à une ligne de cache, il se peut que plusieurs erreurs se soient accumulées, ce qui dépasse les capacités de correction de l'ECC. Dans ce cas, il existe une solution appelée le ''memory scrubbing'', qui permet de résoudre le problème au prix d'un certain cout en performance. Pour rappel, l'idée est de vérifier les lignes de caches régulièrement, pour éviter que les erreurs s'accumulent. Par exemple, on peut vérifier chaque ligne de cache toutes les N millisecondes, et corriger une éventuelle erreur lors de cette vérification. En faisant des vérifications régulières, on garantir que les erreurs n'ont pas le temps de s'accumuler, sauf en cas de malchance avec des erreurs très proches dans le temps. Il ne s'agit pas d'un rafraichissement mémoire, car les SRAM ne s'effacent pas), mais ça a un effet similaire. Et évidemment, le ''memory scrubbing'' a un cout en performance. On peut faire une comparaison avec le rafraichissement mémoire : les rafraichissement réguliers réduisent les performances, car cela fait des accès en plus. Des accès qui sont de plus timés à des instants bien précis qui ne sont pas forcément les plus adéquats. Il est possible qu'un rafraichissement ait lieu en même temps qu'un accès mémoire et le rafraichissement a la priorité, ce qui réduit les performances. La même chose arrive avec les vérifications du ''memory scrubbing''. Malgré tout, la technique a été utilisée sur les caches de certains processeurs commerciaux, dont des processeurs AMD Athlon et Athlon 64. Elle est surtout utilisable sur les caches L2/L3, pour lesquels le cout du pseudo-rafraichissement est acceptable. ==Un exemple de cache : le cache d'instruction== La grande majorité des processeurs utilise deux caches L1 séparés : un '''cache d'instructions''' dédié aux instructions, et un autre pour les données. Une telle organisation permet de charger une instruction tout en lisant une donnée en même temps. Notons que seul le cache L1 est ainsi séparé entre cache de données et d'instructions. Le cache d’instruction se situe en théorie entre l'unité de chargement et l'unité de décodage. En effet, ce cache prend en entrée une adresse et fournit une instruction. L'adresse est fournie par le ''program counter'', l'instruction est envoyée dans l'unité de décodage. Le cache se situe donc entre les deux. Le cache de données L1 est connecté au chemin de données, et notamment aux unités de communication avec la mémoire, pas au séquenceur. [[File:Caches L1 et positions dans le processeur.png|centre|vignette|upright=2.5|Caches L1 et positions dans le processeur]] Les deux caches sont reliés au processeur par des bus séparés, l'ensemble ressemble à une architecture Harvard, mais où les caches remplacent les mémoires RAM/ROM. Le cache d'instruction prend la place de la mémoire ROM et le cache de données prend la place de la mémoire RAM. Évidemment, il y a des niveaux de caches en dessous des caches de données/instruction, et ceux-ci contiennent à la fois données et instructions, les deux ne sont pas séparées dans des mémoires/caches séparés. Raison pour laquelle l'ensemble est appelé une '''architecture Harvard modifiée'''. Architecture Harvard, car l'accès aux données et instructions se font par des voies séparées pour le processeur, modifiée car la séparation n'est effective que pour le cache L1 et pas les autres niveaux de cache, et encore moins la RAM. Sur les processeurs modernes, il arrive très souvent que le processeur doive charger une instruction et lire/écrire une donnée en même temps. Et à vrai dire, c'est la règle plus que l'exception. L'usage d'une architecture Harvard modifiée permet cela très facilement : on peut accéder au cache d'instruction via un bus, et au cache de donnée avec l'autre ===Pourquoi scinder le cache L1 en cache d'instruction et de données=== L'usage d'un cache d’instruction séparé du cache de données est à contraster avec l'usage d'un cache L1 multiport unique, capable de mémoriser à la fois instructions et données. Les deux solutions sont possibles ont été utilisées. Les premiers processeurs avaient un cache L1 unique et multiport, mais ce n'est plus le cas sur les processeurs modernes, car les contraintes ne sont pas les mêmes. Le compromis à faire est celui entre deux petits caches rapides et un gros cache plus lent. Pour rappel, plus un cache est petit, plus il est rapide et chauffe moins. Donc au lieu d'utiliser, par exemple, un gros cache lent de 64 Kibioctets, on utilise deux caches de 32 kibioctets, plus rapides. La capacité totale est la même, mais le temps d'accès plus faible. En termes de temps d'accès, la meilleure solution est celle des deux caches simple port. Mais pour ce qui est de l'économie de circuits, c'est moins évident. Entre deux mémoires simple port et une mémoire multiport, la différence en termes de transistors est ambigüe et dépend de la capacité des caches. La différence est surtout notable pour les gros caches, moins pour les petits caches. Il faut aussi tenir compte de la capacité effective. Avec deux caches séparés, la répartition de la capacité du cache L1 est fixée une bonne fois pour toutes. Par exemple, avec un cache d'instruction de 32 KB et un cache de données de 32 KB, impossible d'allouer 40 KB aux données et 20 aux instructions. Alors qu'avec un cache L1 unique de 64 KB, on pourrait le faire sans soucis. La répartition se fait naturellement, en fonction de la politique de remplacement du cache et est proche de l'optimal. C'est là un désavantage des caches d'instructions/données séparés : une capacité effective moindre. Tout cela explique pourquoi le cache L1 est le seul à être ainsi scindé en deux, avec une séparation entre instructions et données : les contraintes au niveau du cache L1 et L2 ne sont pas les mêmes. Pour les caches L1, le temps d'accès est plus important que la capacité, ce qui favorise les caches séparés. Par contre, pour les caches L2/L3/L4, le temps d'accès n'est pas déterminant, alors que la capacité effective et l'économie en circuits sont significatives. ===La connexion des caches L1 avec le cache L2=== Pour les connexions avec le cache L2, tout dépend du processeur. Certains utilisent un cache L2 multiport, qui permet aux deux caches L1 de lire ou écrire dans le cache L2 simultanément. [[File:Cache d'instructions.png|centre|vignette|upright=1.5|Cache d'instructions.]] Si le cache L2 ne gère pas les accès simultanés, il n'y a qu'un seul bus relié aux caches L1 et au cache L2. On doit effectuer un arbitrage pour décider quel cache a la priorité, chose qui est réalisé par un circuit d'arbitrage spécialisé. [[File:Circuit d'arbitrage du cache.png|centre|vignette|upright=1.5|Circuit d'arbitrage du cache.]] Généralement, les caches d'instructions peuvent se permettre d'être plus petits que les caches de données, car les programmes sont souvent plus petits que les données manipulées. Songez que des programmes de quelques mébioctets peuvent parfois remplir la RAM avec plusieurs gibioctets de données. Lancez votre navigateur internet et ouvrez une page web un peu chargée, pour vous en convaincre ! ===Les spécificités du cache d'instruction : lecture seule, bloquant, etc=== Les instructions sont rarement modifiées ou accédées en écritures, contrairement aux données. Et cela permet d'utiliser un cache simplifié pour les instructions. Autant un cache généraliste doit permettre les lectures et écritures depuis le processeur (avec les échanges avec la RAM), autant un cache d'instruction peut se contenter des lectures provenant du CPU et des échanges avec la RAM. Le cache d'instructions est donc très souvent en « lecture seule » : le processeur ne peut pas écrire dedans, mais juste le lire ou charger des instructions dedans. Un cache d'instruction est donc plus simple qu'un cache pour les données : on peut retirer les circuits en charge de l'écriture (mais on doit laisser un port d'écriture pour charger les instructions dedans). Le gain en circuits permet d'utiliser un cache d'instruction plus gros ou au contraire de laisser de la place pour le cache de données. Le gain en termes de capacité compense alors un peu les inconvénients des caches séparés. Par contre, cela complique la gestion du code automodifiant, c'est-à-dire des programmes dont certaines instructions vont aller en modifier d'autres, ce qui sert pour faire de l'optimisation ou est utilisé pour compresser ou cacher un programme (les virus informatiques utilisent beaucoup de genre de procédés). Quand le processeur exécute ce genre de code, il ne peut pas écrire dans ce cache L1 d'instructions, mais doit écrire dans le cache L2 ou en RAM, avant de recharger les instructions modifiées dans le cache L1. Cela qui prend du temps et peut parfois donner lieu à des erreurs si le cache L1 n'est pas mis à jour. Les algorithmes de remplacement des lignes de cache optimaux pour les données ne le sont pas pour les instructions, de même que la taille optimale du cache, la taille des lignes de cache optimale, ou même les algorithmes de préchargement. Par exemple, pour le remplacement des lignes de cache, un simple algorithme LRU est presque optimal pour les instructions, autant il peut donner de mauvaises performances quand on manipule beaucoup de tableaux. Cela justifie d'utiliser des caches spécialisés pour chacune. On peut adapter le cache d'instruction à son contenu, ce qui le rend plus rapide ou plus petit à performance égale. Les caches d'instructions sont généralement des caches bloquants. Il ne servirait à rien de rendre un cache d'instruction non-bloquant, le cout en circuits ne se traduirait pas par une augmentation significative des performances. À l'opposé, les caches de données sont non-bloquants sur les architectures modernes, pour des raisons de performance. Ce qui rend la séparation assez intéressante, les deux caches ayant des besoins différents et des implémentations différentes, cela permet d'optimiser le cout en transistors des caches. ===L'impact du cache d'instruction sur les performances=== Sur les architectures conventionnelles, le cache d'instruction a plus d'impact sur les performances que le cache de données. La raison principale est que les instructions ont une meilleure localité spatiale et temporelle que pour les données. Pour la localité spatiale, les instructions consécutives se suivent en mémoire, alors que rien ne garantit que des données utilisées ensemble soient regroupées en mémoire. Pour localité temporelle, elle est très variable pour les données, mais très courante pour les instructions du fait de l'usage fréquent des boucles et des fonctions. : La présence de branchements atténue la localité temporelle des instruction, sauf que la majorité des branchements sautent à un endroit très proche, seuls les appels de fonction brisent la localité spatiale. La conséquence est qu'il arrive que certains CPU aient un cache L1 d'instruction plus gros que celui pour les données. On parle alors de '''cache L1 asymétriques'''. Un exemple est celui des processeurs AMD de microarchitecture Zen, dont le cache d'instruction était deux fois plus gros que le cache de données. Leur cache d'instruction faisait 64 kibioctets, contre seulement 32 pour le cache de données. D'ailleurs, il existe des processeurs assez extrêmes qui se contentent d'un cache d'instruction unique, sans cache de données. C'est le cas sur les processeurs vectoriels ou les GPU que nous verrons dans les chapitres de fin de ce wikilivres. De tels processeurs sont spécialisés dans la manipulation de tableaux de données, traitement qui a une faible localité temporelle. En conséquence, utiliser un cache de données n'est pas vraiment utile, voire peu être contreproductif, alors qu'un cache d’instruction fonctionne parfaitement. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Compléments sur les mémoires de masse | prevText=Compléments sur les mémoires de masse | next=Le préchargement | nextText=Le préchargement }} </noinclude> hmdon52xzocibtzkcxg9d23zfwrkdus 765128 765127 2026-04-26T16:37:25Z Mewtow 31375 /* Le contrôleur de cache 82385 pour les CPU Intel 386 */ 765128 wikitext text/x-wiki Le cache est une mémoire intercalée entre la mémoire et un processeur, plus rarement à l'intérieur d'un périphérique. Il est souvent fabriquée avec de la mémoire SRAM, parfois avec de l'eDRAM. Sans lui, on se croirait à l'âge de pierre tellement nos PC seraient lents ! En effet, la mémoire est très lente comparée au processeur. Le temps mis pour accéder à la mémoire est du temps durant lequel le processeur n'exécute pas d'instruction (sauf cas particuliers impliquant un pipeline). Pour diminuer ce temps d'attente, il a été décidé d'intercaler une mémoire petite mais rapide, entre le processeur et la mémoire. Ainsi, le processeur accède à un cache très rapide plutôt qu'à une RAM beaucoup plus lente. ==L'accès au cache== Le cache contient une copie de certaines données présentes en RAM. La copie présente dans le cache est accessible bien plus rapidement que celle en RAM, vu que le cache est plus rapide. Mais seule une petite partie de ces données sont copiées dans le cache, les autres données devant être lues ou écrites dans la RAM. Toujours est-il que le cache contient une copie des dernières données accédées par le processeur. Une donnée est copiée dans la mémoire cache quand elle est lue ou écrite par le processeur. Le processeur conserve une copie de la donnée dans le cache après son premier accès. Les lectures/écritures suivantes se feront alors directement dans le cache. Évidemment, au fur et à mesure des accès, certaines données anciennes sont éliminées du cache pour faire de la place aux nouveaux entrants, comme nous le verrons plus tard. [[File:Principe d'une mémoire cache.gif|centre|vignette|upright=2|Principe d'une mémoire cache.]] La mémoire cache est invisible pour le programmeur, qui ne peut pas déceler celles-ci dans l'assembleur. Les accès mémoire se font de la même manière avec ou sans le cache. La raison à cela est que le cache intercepte les accès mémoire et y répond s'il en a la capacité. Par exemple, si le cache intercepte une lecture à une adresse et que le contenu de cette adresse est dans le cache, le cache va outrepasser la mémoire RAM et la donnée sera envoyée par le cache au lieu d'être lue en RAM. par contre, si un accès se fait à une adresse pour laquelle le cache n'a pas la donnée, alors l'accès mémoire sera effectué par la RAM de la même manière que si le cache n'était pas là. [[File:Accès au cache.png|centre|vignette|upright=2|Accès au cache]] ===Les succès et défauts de caches=== Tout accès mémoire est intercepté par le cache, qui vérifie si la donnée demandée est présente ou non dans le cache. Si la donnée voulue est présente dans le cache, on a un '''succès de cache''' (''cache hit'') et on accède à la donnée depuis le cache. Sinon, c'est un '''défaut de cache''' (''cache miss'') et on est obligé d’accéder à la RAM. Les défauts de cache peuvent avoir plusieurs origines. Tout ce qu'il faut savoir est que lorsque le processeur accède à une donnée ou une instruction pour la première fois, il la place dans la mémoire cache car elle a de bonnes chances d'être réutilisée prochainement. La raison à cela est qu'un programme a tendance à réutiliser les instructions et données qui ont été accédées dans le passé : c'est le ''principe de localité temporelle''. Bien évidement, cela dépend du programme, de la façon dont celui-ci est programmé et accède à ses données et du traitement qu'il fait, mais c'est souvent vrai en général. La première cause des défauts de cache est liée à la taille du cache. À force de charger des données/instructions dans le cache, le cache fini par être trop petit pour conserver les anciennes données. Le cache doit bien finir par faire de la place en supprimant les anciennes données, qui ont peu de chances d'être réutilisées. Ces anciennes données éliminées du cache peuvent cependant être accédées plus tard. Tout prochain accès à cette donnée mènera à un cache miss. C'est ce qu'on appelle un ''Capacity Cache Miss'', ou encore '''défaut de capacité'''. Les seules solutions pour éviter cela consistent à augmenter la taille du cache ou à optimiser le programme exécuté (voir plus bas). Une autre raison pour un défaut est donc la suivante. Lorsqu'on exécute à une instruction ou qu'on accède à donnée pour la première fois, celle-ci n'a pas encore été chargée dans le cache. Le défaut de cache est inévitable : ce genre de cache miss s'appelle un ''Cold Miss'', ou encore un '''défaut à froid'''. De tels défauts sont presque impossibles à éliminer, sauf à utiliser des techniques de préchargement qui chargent à l'avance des données potentiellement utiles. Ces méthodes de préchargement se basent sur le principe de localité spatiale, à savoir le fait que les programmes ont tendance à accéder à des données proches en mémoire. Pour donner un exemple, les instructions d'un programme sont placées en mémoire dans l’ordre dans lequel on les exécute : la prochaine instruction à exécuter est souvent placée juste après l'instruction en cours (sauf avec les branchements). Quand on accède à une donnée ou une instruction, le cache peut précharger les données adjacentes pour en profiter. Nous parlerons de ces techniques de préchargement dans un chapitre dédié, vers la fin du cours. ===Le fonctionnement du cache, vu du processeur=== Vu du processeur, le cache prend en entrée toutes les informations nécessaires pour effectuer un accès mémoire : des signaux de commande, une adresse et la donnée à écrire si besoin. Tout cela est passé en entrée du cache, celui-ci répondant aux accès mémoire via divers bits de contrôles, que le processeur peut lire à souhait. Le cache fournit aussi la donnée à lire, pour les lectures, sur une sortie, connectée directement au bus mémoire/processeur. Globalement, le cache a une capacité limitée, mais il prend en entrée des adresses complètes. Par exemple, sur un processeur 64 bits, le cache prend en entrée des adresses de 64 bits (sauf si optimisations), même si le cache en question ne fait que quelques mébioctets. Les caches sont souvent des mémoires multiports, surtout sur les processeurs récents. Les caches simple port sont rares, mêmes s'ils existent et ont existé par le passé. les caches double port sont eux plus fréquents, et ont généralement un port d'écriture séparé du port de lecture. Mais les caches récents ont plusieurs ports de lecture/écriture et sont capables de gérer plusieurs accès mémoire simultanés. Les données présentes dans le cache sont (pré)chargées depuis la mémoire, ce qui fait que toute donnée dans le cache est la copie d'une donnée en mémoire RAM. Le cache doit faire la correspondance entre une donnée du cache et l'adresse mémoire correspondante. Du point de vue du fonctionnement, on peut voir le cache comme une sorte de table de correspondance, qui mémorise des données, chacune étant associée à son adresse mémoire. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Cela vaut du point de vue du processeur, le fonctionnement interne du cache étant quelque peu différent selon le cache. Il existe des caches dont le fonctionnement interne est bien celui d'une table de correspondance matérielle, d'autres qui sont beaucoup plus optimisés. [[File:Fonctionnement d'une mémoire associative à correspondance.png|centre|vignette|upright=2|Fonctionnement simplifié d'une mémoire cache : les adresses sont dans la colonne de gauche, les données sont dans la colonne de droite. On voit qu'on envoie l'adresse au cache, que celui-ci répond en renvoyant la donnée associée.]] ==La performance des mémoires caches== L'analyse de la performance des mémoires caches est plus riche pour celle des autres mémoires. Sa performance dépend de beaucoup de paramètres, mais on peut cependant citer les principaux. Les deux premiers sont tout bonnement sa latence et son débit, comme pour n'importe quelle autre mémoire. La latence est plus importante que son débit, car le processeur est généralement plus rapide que le cache et qu'il n'aime pas attendre. Mais le critère le plus important pour un cache est sa capacité à empêcher des accès mémoire, son efficacité. Plus les accès mémoire sont servis par le cache au lieu de la RAM, meilleures seront les performances. Pour résumer, la performance d'un cache est surtout caractérisée par deux métriques : le taux de défaut, qui correspond à l’efficacité du cache, et la latence du cache. ===Le taux de succès/défaut=== Le '''taux de succès''' (hit ratio) est un premier indicateur des performances du cache, mais un indicateur assez imparfait. C'est le pourcentage d'accès mémoire qui ne déclenchent pas de défaut de cache. Plus il est élevé, plus le processeur accède au cache à la place de la RAM et plus le cache est efficace. Certains chercheurs préfèrent utiliser le '''taux de défauts''', à savoir le pourcentage d'accès mémoire qui entraînent un défaut de cache. Plus il est bas, meilleures sont les performances. Le taux de défaut est relié au taux de succès par l'équation <math>T_\text{succes} = 1 - T_\text{defaut}</math>. Par définition, il est égal à : : <math>\text{Taux de défauts de cache} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d’accès mémoires}}</math> Plutôt que de comparer le nombre de défauts/succès de cache au nombre d'accès mémoire, il est aussi possible de diviser le nombre de défauts par le nombre total d'instructions. On obtient alors le '''taux de défauts/succès par instruction''', une autre métrique utile. Par définition, elle est égale à : : <math>\text{Taux de défauts par instruction} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d'instructions}} = \text{Taux de défauts de cache} \times \frac{\text{Nombre d’accès mémoires}}{\text{Nombre d'instructions}}</math> Si certains défauts de cache sont inévitables quel que soit le cache, comme les défauts à froids, mentionnés plus haut, d'autres défauts peuvent être évités en augmentant la capacité du cache. C'est le cas des défauts de capacité qui sont causés par un accès à une donnée qui a été éliminée du cache faute de place. Plus le cache est gros, moins il a de chances d'être rempli, moins il doit rapatrier de données, plus son taux de succès augmente. Mais nous reviendrons sur le lien entre taille du cache et taux de défaut plus bas. Le taux de succès ne dépend pas que du cache, mais aussi de la conception des programmes exécutés. Une bonne utilisation du cache (ainsi que de la mémoire virtuelle) repose sur le programmeur qui doit prendre en compte les principes de localités dès la conception de ses programmes. Par exemple, un programmeur peut parfaitement tenir compte du cache au niveau de son algorithme : on peut citer l'existence des algorithmes ''cache oblivious'', qui sont conçus pour être optimaux quelle que soit la taille du cache. Le programmeur peut aussi choisir ses structures de données de manière à améliorer la localité. Par exemple, un tableau est une structure de donnée respectant le principe de localité spatiale, tandis qu'une liste chaînée ou un arbre n'en sont pas (bien qu'on puisse les implémenter de façon à limiter la casse). D'autres optimisations sont parfois possibles : par exemple, le sens de parcours d'un tableau multidimensionnel peut faire une grosse différence. Cela permet des gains très intéressants pouvant se mesurer avec des nombres à deux ou trois chiffres. Je vous recommande, si vous êtes programmeur, de vous renseigner le plus possible sur les optimisations de code ou algorithmiques qui concernent le cache : il vous suffira de chercher sur Google. Il y a une citation qui résume bien cela, prononcée par un certain Terje Mathisen. Si vous ne le connaissez pas, cet homme est un vieux programmeur (du temps durant lequel on codait encore en assembleur), grand gourou de l’optimisation, qui a notamment travaillé sur le moteur de Quake 3 Arena. {{BlocCitation|Almost all programming can be viewed as an exercise in caching.|auteur=Terje Mathisen}} ===La latence moyenne d'un cache=== Le temps mis pour lire ou écrire une donnée varie en présence d'un cache. Certaines lectures/écritures vont atterrir directement dans le cache (succès) tandis que d'autres devront aller chercher leur contenu en mémoire RAM (défaut de cache). Dans tous les cas, qu'il y ait défaut ou non, le cache sera consulté et mettra un certain temps à répondre, égal au temps de latence du cache. Tous les accès mémoires auront donc une durée au moins égale au temps de latence du cache, qui sera notée <math>T_c</math>. En cas de succès, le cache aura effectué la lecture ou l'écriture, et aucune action supplémentaire n'est requise. Ce qui n'est pas le cas en cas de défaut : le processeur devra aller lire/écrire la donnée en RAM, ce qui prend un temps supplémentaire égal au temps de latence de la mémoire RAM. Un défaut ajoute donc un temps, une pénalité, à l'accès mémoire. Dans ce qui suivra, le temps d'accès à la RAM sera noté <math>T_m</math>. Fort de ces informations, nous pouvons calculer le temps de latence moyen d'un accès mémoire, qui est la somme du temps d'accès au cache (pour tous les accès mémoire), multiplié par le temps lié aux défauts. On a alors : : <math>T = T_c + \text{Taux de défaut} \times T_m</math> On voit que plus le taux de succès est élevé, plus le temps de latence moyen sera bas, et inversement. Ce qui explique l'influence du taux de succès sur les performances du cache, influence assez importante sur les processeurs actuels. De nos jours, le temps que passe le processeur dans les défauts de cache devient de plus en plus un problème au fil du temps, et gérer correctement le cache est une nécessité, particulièrement sur les processeurs multi-cœurs. Il faut dire que la différence de vitesse entre processeur et mémoire est tellement importante que les défauts de cache sont très lents : alors qu'un succès de cache va prendre entre 1 et 5 cycles d'horloge, un cache miss fera plus dans les 400-1000 cycles d'horloge. Tout ce temps sera du temps de perdu que le processeur aura du mal à mitiger. Autant dire que réduire les défauts de cache est beaucoup plus efficace que d'optimiser les calculs effectués par le processeur (erreur courante chez de nombreux programmeurs, notamment débutants). ===L'impact de la taille du cache sur le taux de défaut et la latence=== Il y a un lien entre taille du cache, taux de défaut, débit binaire et latence moyenne. Globalement, plus un cache est gros, plus il est lent. Simple application de la notion de hiérarchie mémoire vue il y a quelques chapitres. Les raisons à cela sont nombreuses, mais nous ne pouvons pas les aborder ici, car il faudrait que nous sachions comment fonctionne un cache et ce qu'il y a à l'intérieur, ce qui sera vu dans la suite du chapitre. Toujours est-il que la latence moyenne d'un cache assez gros est assez importante. De même, le débit binaire d'un cache diminue avec sa taille, mais dans une moindre mesure. Les petits caches ont donc un gros débit binaire et une faible latence, alors que c'est l'inverse pour les gros caches. Une grande capacité de cache améliore le taux de succès, mais cela se fait au détriment de son temps de latence et de son débit, ce qui fait qu'il y a un compromis assez difficile à trouver entre taille du cache, latence et débit. Il peut arriver qu'augmenter la taille du cache augmente son temps d'accès au point d’entraîner une baisse de performance. Par exemple, les processeurs Nehalem d'Intel ont vus leurs performances dans certains jeux vidéos baisser de 2 à 3 %, malgré de nombreuses améliorations architecturales, parce que la latence du cache L1 avait augmentée de 2 cycles d'horloge. Pour avoir une petite idée du compromis à faire, regardons la relation entre taille du cache et taux de défaut. Il existe une relation approximative entre ces deux variables, appelée la '''loi de puissance des défauts de cache'''. Elle donne le nombre total de défaut de cache en fonction de la taille du cache et de deux autres paramètres. Voici cette loi : : <math>\text{Taux de défauts de cache} \approx K \times \text{Taille du cache}^{- \alpha }</math>, avec <math>K</math> et <math>\alpha</math> deux coefficients qui dépendent du programme exécuté. Le coefficient <math>\alpha</math> est généralement compris entre 0.3 et 0.7, guère plus, et varie suivant le programme exécuté. Précisons que cette loi ne marche que si le cache est assez petit par rapport aux données à utiliser. Pour un cache assez gros et des données très petites, la relation précédente est mise en défaut. Pour s'en rendre compte, il suffit d'étudier le cas extrême où toutes les données nécessaires tiennent dans le cache. Dans ce cas, il n'y a qu'un nombre fixe de défauts de cache : autant qu'il faut charger de données dans le cache. Le nombre de défauts de cache observé dans cette situation n'est autre que le coefficient <math>K</math> de la situation précédente, mais il n'y a aucune dépendance entre taux de défaut et taille du cache. L'origine de cette relation s'explique quand on regarde combien de fois chaque donnée est réutilisée lors de l’exécution d'un programme. La plupart des données finissent par être ré-accédées à un moment ou un autre et il se passe un certain temps entre deux accès à une même donnée. Sur la plupart des programmes, les observations montrent que beaucoup de réutilisations de données se font après un temps très court et qu'inversement, peu de ré-accès se font après un temps inter-accès long. Si on compte le nombre de réutilisation qui ont un temps inter-accès bien précis, on retrouve une loi de puissance identique à celle vue précédemment : : <math>\text{Nombre de réaccès avec un temps inter-accès égal à t} \approx K \times t^{- \beta}</math>, avec t le temps moyen entre deux réutilisations. Le coefficient <math>\beta</math> est ici compris entre 1.7 et 1.3. De manière générale, les coefficients <math>\alpha</math> et <math>\beta</math> sont reliés par la relation <math>\alpha = 1 - \beta</math>, ce qui montre qu'il y a un lien entre les deux relations. Précisons cependant que la loi de puissance précédente ne vaut pas pour tous les programmes informatiques, mais seulement pour la plupart d’entre eux. Il n'est pas rare de trouver quelques programmes pour lesquels les accès aux données sont relativement prédictibles et où une bonne optimisation du code fait que la loi de puissance précédente n'est pas valide. La loi de puissance des défauts de cache peut se démontrer à partir de la relation précédente, sous certaines hypothèses. Si un suppose que le cache est assez petit par rapport aux données, alors les deux relations sont équivalentes. L'idée qui se cache derrière la démonstration est que si le temps entre deux accès à une donnée est trop long, alors la donnée accédée aura plus de chance d'être rapatriée en RAM, ce qui cause un défaut de cache. La chance de rapatriement dépend de la taille du cache, un cache plus gros peut conserver plus de données et a donc un temps avant rapatriement plus long. ==Les lignes de cache et leurs tags== Du point de vue du processeur, les lectures et écritures se font mot mémoire par mot mémoire. Un processeur avec des entiers de 64 bits recoit des données de 64 bits de la part du cache, et y écrit des mots de 64 bits. Mais quand on regarde comment sont stockées les données à l'intérieur du cache, les choses sont différentes. ===Les lignes de cache=== Les données sont mémorisées dans le cache par blocs de plusieurs bytes, d'environ 64 à 256 octets chacun, qui portent le nom de '''lignes de cache'''. Les lignes de cache sont l'unité de stockage que l'on trouve à l'intérieur du cache, mais elles servent aussi d'unité de transaction avec la mémoire RAM. Sur les caches actuels, on transfère les données entre le cache et la RAM ligne de cache par ligne de cache, dans la limite de la taille du bus mémoire. Mais d'autres caches plus anciens permettaient de faire des transferts plus fins. C’est-à-dire qu'on pouvait mettre à jour quelques octets dans une ligne de cache sans avoir à la recopier intégralement depuis ou dans la mémoire RAM. En théorie, on pourrait imaginer des caches où les données sont stockées différemment, où l'unité serait le mot mémoire, par exemple. Par exemple, sur un processeur 64 bits, on aurait une ligne de cache de 64 bits. Cela aurait l'avantage de la simplicité : les transferts entre le processeur et la mémoire serait de même taille, l'intérieur du cache ressemblerait à son interface montrée au processeur. Mais cela aurait quelques défauts qui sont compensés par l'organisation en lignes de cache de grande taille. Le premier avantage des lignes de cache est lié à la localité spatiale, la tendance qu'on les programmes à accéder à des données proches les unes des autres. Des accès mémoires consécutifs ont tendance à se faire à des adresses proches, qui ont de bonnes chances d'être dans la même ligne de cache. Et des accès consécutifs à une même ligne de cache sont plus rapides que des accès à deux lignes distinctes. Une autre raison est tout simplement que cela simplifie considérablement la circuiterie du cache. Pour une capacité identique, il vaut mieux avoir peu de lignes de cache assez grosses, que beaucoup de petites lignes de cache. La raison est que les circuits du cache, comme le décodeur, l'encodeur et autres, ont moins de sorties et sont donc plus simples. ===L'alignement des lignes de cache=== Les lignes de cache sont des blocs de plusieurs dizaines à centaines de bytes, dont la taille est presque toujours une puissance de deux. De plus, les lignes de cache sont alignées en mémoire. Nous avions déjà abordé la notion d'alignement mémoire dans un chapitre précédent, mais le concept d'alignement des lignes de cache est quelque peu différent. Quand nous avions parlé d'alignement auparavant, il s'agissait de l'alignement des données manipulées par le processeur, qui faisait partie du jeu d'instruction du processeur. Ici, nous parlons d'un alignement totalement différent, invisible pour le programmeur, sans lien avec le jeu d’instruction. Voyons de quoi il retourne. Concrètement, cela veut dire que du point de vue du cache, la RAM est découpée en blocs qui font la même taille qu'une ligne de cache, aux positions prédéterminées, sans recouvrement entre les blocs. Par exemple, pour un cache dont les lignes de cache font 256 octets, le premier bloc est à l'adresse 0, le second est 256 octets plus loin, c'est à dire à l'adresse 256, le troisième à l'adresse 512, la quatrième à l'adresse 768, etc. Une ligne de cache de 256 octets contiendra une donnée provenant d'un bloc de RAM de 256 octets, dont l'adresse est systématiquement un multiple de 256. Il n'est pas possible qu'une ligne de cache contienne un bloc de 256 octets dont l'adresse du premier octet serait l'adresse 64, ou l'adresse 32, par exemple. En clair, les adresses de ces blocs sont des multiples de la taille de la ligne de cache, de la taille des blocs. Cela rappelle les contraintes d'alignement vues dans le chapitre "Le modèle mémoire : alignement et boutisme", mais appliquées aux lignes de cache. L'alignement des lignes de cache a des conséquences pratiques pour la conception des caches. Notons qu'il est en théorie possible d'avoir des caches dont les lignes de cache ne sont pas alignées, mais cela poserait des problèmes majeurs. Il serait en effet possible qu'une donnée soit présente dans deux lignes de cache à la fois. Par exemple, prenons le cas où une ligne de cache de 256 commence à l'adresse 64 et une autre ligne de cache commence à l'adresse 0. L'adresse 128 serait dans les deux lignes de cache ! Et cela poserait des problèmes lors des lectures, mais encore plus lors des écritures. C'est pour éviter ce genre de problèmes que les lignes de cache sont alignées avec la mémoire RAM dans tous les caches existants. L'alignement des lignes de cache est une chose que les programmeurs doivent parfois prendre en compte quand ils écrivent du code ultra-optimisé, destiné à des programmes demandant des performances extrêmes. Il arrive que les contraintes d'alignement posent des problèmes. Nous avions vu dans le chapitre sur le boutisme et l'alignement qu'il valait mieux gérer l'alignement des variables des structures de données, pour éviter les accès non-alignés avec le bus mémoire. La même chose est possible, mais pour l'alignement avec des lignes de cache. Typiquement, l'idéal est que, pour une structure de donnée, on puisse en mettre un nombre entier dans une ligne de cache. Ou alors, si la structure est vraiment grande, que celle-ci occupe un nombre entier de lignes de cache. Si ce n'est pas le cas, il y a un risque d'accès non-alignés, c'est à dire qu'une structure se retrouve à cheval sur deux lignes de cache, avec les défauts que cela implique. ===Le tag d'une ligne de cache=== Plus haut, nous avions dit que le cache mémorise, pour chaque ligne de cache, l'adresse RAM associée. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Mais du fait de l'organisation du cache en lignes de cache de grande taille, qui sont de plus alignées en mémoire, il faut nuancer cette affirmation. Le cache ne mémorise pas la totalité de l'adresse, ce qui serait inutile. L'alignement des lignes de cache en RAM fait que les bits de poids faible de l'adresse ne sont pas à prendre en compte pour l'association adresse-ligne de cache. Dans ces conditions, on mémorise seulement la partie utile de l'adresse mémoire correspondante, qui forme ce qu'on appelle le '''tag'''. Le reste de l'adresse indique quelle est la position de la donnée dans la ligne de cache. Par exemple, prenons le cas où le processeur gère des nombres entiers de 64 bits (8 octets) et des lignes de cache de 128 octets : chaque ligne de cache contient donc 16 entiers. Si le processeur veut lire ou écrire un entier bien précis, il doit préciser sa place dans la ligne de cache. Et ce sont les bits de l'adresse mémoire non-inclus dans le cache qui permettent de faire ça. En clair, une adresse mémoire à lire/écrire est interprété par le cache comme la concaténation d'un tag et de la position de la donnée dans la ligne de cache correspondante. [[File:Adressage d'un cache totalement associatif.png|centre|vignette|upright=2|Adressage d'un cache totalement associatif]] Le cache est donc une grande table de correspondance entre tags et lignes de cache. Lors d'un accès mémoire, le cache extrait le tag de l'adresse à lire ou écrire, et le compare avec les tags de chaque ligne de cache. Si une ligne contient ce tag, alors c'est que cette ligne correspond à l'adresse, et c'est un défaut de cache sinon. Lors d'un succès de cache, la ligne de cache est lue depuis le cache et envoyée à un multiplexeur qui sélectionne la donnée à lire dans la ligne de cache. Le fonctionnement est similaire pour une écriture : la donnée à écrire passe dans un démultiplexeur, qui envoie la donnée au bon endroit dans la ligne de cache sélectionnée. [[File:Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.png|centre|vignette|upright=2|Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.]] ===Le contenu d'une ligne de cache=== Dans ce qui va suivre, nous allons considérer que chaque ligne de cache mémorise son tag, les données de la ligne de cache proprement dit, et quelques bits de contrôle annexes qui varient suivant le cache considéré. [[File:Tag d'une ligne de cache.png|centre|vignette|upright=2|Tag d'une ligne de cache.]] Les caches modernes incluent de nombreux bits de contrôle, mais deux d'entre eux sont communs à presque tous les caches modernes : le bit ''Dirty'' et le bit ''Valid''. Le '''bit ''Valid''''' indique si la ligne de cache contient des données valides ou non. Si le bit ''Valid'' est à 0, la ligne de cache est en état valide, à savoir qu'elle contient des données et n'est pas vide. Par contre, si ce bit est à 1, la ligne de cache est invalide et son contenu ne peut pas être lu ou écrit. L'utilité de ce bit est qu'il permet d'effacer une ligne de cache très rapidement : il suffit de mettre ce bit à 0. Il existe des situations où le cache doit être effacé, on dit alors qu'il est invalidé. Une section de ce chapitre sera dédié à l'invalidation du cache. Le '''bit ''Dirty''''' indique qu'une ligne de cache a été modifiée. Par modifiée, on veut dire que le processeur a écrit dedans, qu'il a modifié la ligne de cache. Mais attention : si la donnée a été modifiée dans le cache, la modification n'est pas forcément propagée en mémoire RAM. Le bit ''dirty'' indique si c'est le cas, si l'écriture a été propagée en mémoire RAM. Il précise que la ligne de cache contient des données modifiées, alors que la RAM a des données initiales non-modifiées. Une ligne de cache avec un bit ''dirty'' à 1 est dite ''dirty'', par métonymie. Nous verrons cela en détail dans la section sur les caches ''write-back'' et ''write-through''. Les caches modernes ajoutent des '''bits de détection/correction d'erreur''' dans les bits de contrôle. Pour rappel, les codes de détection/correction d'erreur permettent de se prémunir contre des erreurs matérielles, qui corrompent les données stockées dans une mémoire, ici une mémoire cache. Ils ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Nous reviendrons dessus dans une section ultérieur de ce chapitre. Sur certains caches assez anciens, on pouvait transférer les lignes de caches morceaux par morceaux. Ces caches avaient des lignes de cache divisées en sous-secteurs, ces sous-secteurs étant des morceaux de ligne de cache qu'on pouvait charger indépendamment les uns des autres (mais qui sont consécutifs en RAM). Chaque secteur avait ses propres bits de contrôle, mais le tag était commun à tous les secteurs. [[File:Cache à secteurs.png|centre|vignette|upright=2.5|Cache à secteurs.]] : Dans ce qui va suivre, le terme "ligne de cache" désignera soit un bloc de données copiées depuis la RAM d'une taille de 64/128/256/... octets, soit la concaténation de ces données avec le tag et des bits de contrôle. Les deux définitions ne sont pas équivalentes, mais l'usage a entériné cet abus de langage. Et il faut avouer que cela rend les explications du chapitre plus simples. ==Les instructions de contrôle du cache== Plus haut, nous avions dit que le cache est totalement transparent du point de vue du programmeur. Le cache contient des copies de données en RAM, le programmeur n'a rien à faire pour utiliser le cache correctement. Mais la réalité est que pour des raisons diverses, des processeurs incorporent des '''instructions de contrôle du cache'''. Il s'agit d’instructions qui agissent sur le contenu du cache. Elles existent pour des raisons diverses qu'on détaillera plus bas, mais il s'agit globalement d'une question de performances ou de nécessité pour le système d'exploitation. ===Les instructions de préchargement=== La première instruction de contrôle du cache est une '''instruction de préchargement''', qui demande à charger un bloc de données dans le cache. Elle prend en opérande une adresse mémoire, et le contenu de cette adresse est chargé dans une ligne de cache. Bien sûr, des contraintes d'alignement sont à prendre en compte : on charge un bloc de la même taille qu'une ligne de cache, aligné en mémoire sur la taille du bloc, qui contient l'adresse. L'instruction de préchargement n'est utile que si l'instruction est exécutée bien avant que la donnée ne soit utilisée/lue/écrite. Cela permet de charger une donnée dans le cache à l'avance, d'où le nom de préchargement donné à cette technique. Mais les processeurs modernes gérent des techniques de préchargement automatique, qui ne requièrent pas d'instructions de préchargement. Le préchargement automatique et les instructions de préchargement sont deux solutions complémentaires, mais qui peuvent se marcher sur les pieds. Nous en reparlerons dans le prochain chapitre, qui sera dédié au préchargement automatique. Il faut noter que les instructions de préchargement peuvent être ignorées par le processeur. Sous certaines conditions, le processeur peut décider que l'instruction de préchargement ne sera pas exécutée. Par exemple, il ne va pas précharger une donnée déjà présente dans le cache. Ou encore, si le bus mémoire est occupé, il ne va pas exécuter le préchargement, par manque de ressources matérielles. ===Les instructions d'invalidation et de ''flush''=== Les instructions ''flush'' regroupent deux types d'instructions qui sont souvent utilisées en même temps. Il s'agit des instructions d'invalidation et de nettoyage (''clean''). Les deux termes proviennent de la terminologie ARM, il n'y a pas de terminologie standardisé pour les noms de ces instructions. Dans les grandes lignes, elles permettent de vider le cache, à savoir de rapatrier son contenu en RAM et de réinitialiser le cache à zéro. Elles sont utilisées par le système d'exploitation lors des commutations de contexte, à savoir quand on passe d'un programme à un autre. Elles sont aussi utilisées lors des appels systèmes et routines d'interruption/exception. L'idée est de vider le cache avant d'exécuter un nouveau programme ou une nouvelle routine. Le nouveau programme aura accès à un cache tout propre, les données de l'ancien programme auront été retirée du cache. Les '''instructions ''clean''''' recopient le contenu de la ligne de cache en RAM. Elles forcent la recopie immédiatement de la ligne de cache en mémoire RAM. Pour faire leur travail, elle vérifient si la ligne de cache a été modifiée, avant de la recopier en RAM. Et pour cela, ils vérifient le bit de contrôle ''dirty'', qui est mis à 1 après une première écriture. Si ce bit est à 0, alors pas besoin de recopier la ligne de cache : elle n'a pas été modifiée, la RAM a déjà la bonne copie. Mais s'il est à 1, le cache et la RAM n'ont pas le même contenu, la recopie s'exécute. Les '''instructions d'invalidation''' permettent d'invalider une ligne de cache, à savoir d'effacer son contenu. Nous verrons à quoi servent ces instructions dans la section sur les changement de processus. Invalider une ligne de cache est une opération optimisée : le cache n'est en réalité pas réellement effacé. À la place, le bit ''Valid'' de chaque ligne de cache est juste mis à 0. Il faut noter que l'invalidation efface les lignes de cache sans se préoccuper de leur contenu. Elle se moque qu'une ligne de cache contienne une donnée modifiée, ''dirty'' ou quoique ce soit : la ligne de cache est effacée, point. Il est possible d'invalider une ligne de cache en fournissant une adresse mémoire, mais il est aussi possible d'invalider le cache tout entier. Le choix entre les deux dépend du mode d'adressage de l'instruction d'invalidation. Parfois, il existe une instruction séparée pour invalider tout le cache, et une autre pour invalider une ligne de cache bien précise. Des instructions séparées sont parfois disponibles pour invalider les caches de données et d'instructions, parfois aussi la TLB (un cache qu'on verra dans quelques chapitres). Il est possible de n'invalider que le cache L1, voire le cache L2. Il faut noter que l'invalidation efface tout le cache, mais ne se préoccupe pas de vérifier si les données ont été modifiées dans le cache. Pour certains caches, comme le cache d'instruction, ce n'est pas un problème, vu qu'il est en "lecture seule". Mais pour les caches de données, les données modifiées sont perdues en cas d'invalidation. Heureusement, il existe des instructions d'invalidation qui fusionnent une instruction ''clean'' et une instruction d'invalidation. Il s'agit d''''instructions d'invalidation spéciales'''. ===Les instructions d'optimisation : instructions non-temporelles et écritures optimisées=== Les '''instructions mémoire non-temporelles''' contournent complètement le cache. Par exemple, une lecture peut lire une donnée, mais celle-ci ne sera pas chargée dans le cache, elle passe directement de la RAM vers les registres. Une section entière de ce chapitre sera dédiée au contournement du cache, à savoir aux situations où les accès mémoire doivent passer directement du processeur à la RAM sans passer par le cache. D'autres instructions assez rares incorporent des indications pour le cache. Par exemple, l'instruction ''load last'' des processeurs POWER PC implique que la donnée ne sera utilisée qu'une seule fois. Elle est donc chargée dans le cache, mais la ligne de cache est configurée de manière à être remplacée très rapidement, typiquement avec une valeur de LRU/LFU adéquate. La donnée est bien chargée dans le cache, au cas où elle doive être relue suite à une mauvaise prédiction de branchement ou autre, chose qu'une lecture non-temporelle (qui contourne le cache) ne fait pas. Des indications de ce type sont appelées des '''''cache hint'''''. L''''instruction ''flush''''' permet de préciser qu'une ligne de cache contient une donnée inutile, qui ne sera pas réutilisée par le programme. Pas besoin de la conserver dans le cache, elle peut laisser sa place à des données plus utiles. Or, sans indication, les algorithmes de remplacement d'une ligne de cache risquent de conserver cette donnée trop longtemps, ce qui entraine une certaine pollution du cache par des données inutiles. Une autre instruction est elle beaucoup plus importante : celle de '''pré-allocation sur écriture'''. Elle sert dans le cas où une ligne de cache est complétement écrite. Par exemple, imaginons qu'on veuille écrire dans une portion de mémoire. Si celle-ci n'est pas dans le cache, le processeur va charger une ligne de cache complète depuis la RAM, écrire dans la ligne de cache, puis recopier la ligne de cache modifiée en mémoire RAM. Une écriture en RAM demande donc de faire une lecture et une écriture. Mais les instructions de pré-allocation sur écriture permettent de prévenir qu'une ligne de cache sera intégralement écrite, et qu'il n'y a donc pas besoin de lire celle-ci depuis la RAM. Notons que l'instruction d'écriture qui suit n'est pas une écriture non-temporelle, vu que les données sont écrites dans la ligne de cache, qui est ensuite envoyée en mémoire RAM dès que nécessaire. De plus, les données écrites peuvent ensuite être relue depuis le cache si nécessaire. Enfin, certains processeurs MIPS incorporent une instruction pour modifier le tag d'une ligne de cache. Elles servent à optimiser les copies mémoire, à savoir quand on copie un bloc de données d'un endroit à un autre. L'idée est de charger le bloc de données dans le cache avec une instruction LOAD/PREFETCH, de modifier le tag pour qu'il pointe vers l'adresse à écrire, et de laisser faire le cache pour que l'écriture se fasse en RAM. Mais les contraintes pour utiliser cette instruction sont assez drastiques : les données doivent être alignées sur la taille d'une ligne de cache, le bloc de départ et d'arrivée (l'original versus la copie) ne doivent pas se recouvrir, etc. ==L'associativité des caches et leur adressage implicite== Lorsqu'on souhaite accéder au cache, il faut trouver quelle est la ligne de cache dont le tag correspond à l'adresse demandée. On peut classifier les caches selon leur stratégie de recherche de la ligne correspondante en trois types de caches : totalement associatifs, directement adressés (''direct mapped'') et associatifs par voie. ===Les caches totalement associatifs=== Avec les caches totalement associatifs, toute donnée chargée depuis la mémoire peut être placée dans n'importe quelle ligne de cache, sans aucune restriction. Ces caches ont un taux de succès très élevé, quand on les compare aux autres caches. [[File:Cache totalement associatif.png|centre|vignette|upright=2|Cache totalement associatif.]] Concevoir un cache totalement associatif peut se faire de deux grandes manières différentes. La première consiste tout simplement à combiner une mémoire associative avec une mémoire RAM, en ajoutant éventuellement quelques circuits annexes. La mémoire associative mémorise les tags, alors que la mémoire RAM mémorise les données de la ligne de cache, éventuellement avec quelques bits de contrôle. La ligne de cache est stockée à une adresse A dans la mémoire RAM et son tag est stocké à la même adresse, mais dans la mémoire CAM. Ce faisant, quand on envoie le tag à la mémoire CAM, elle renvoie l'adresse de la ligne de cache dans la mémoire RAM. Cette adresse est alors envoyée directement sur le bus d'adresse de la RAM, et la lecture est effectuée automatiquement. Il faut ajouter quelques circuits annexes pour garantir que les écritures se passent correctement dans les deux mémoires, mais rien de bien terrible. [[File:Cache fabriqué avec une mémoire associative et une RAM.png|centre|vignette|upright=3|Cache fabriqué avec une mémoire associative et une RAM]] Il est cependant possible d'optimiser un tel cache, en fusionnant la mémoire CAM et la mémoire RAM, afin d'éliminer des circuits redondants. Pour comprendre pourquoi, rappelons que les mémoires CAM sont composées d'un plan mémoire, d'un paquet de comparateurs et d'un encodeur. Quant à la mémoire RAM, elle est composée d'un décodeur connecté au plan mémoire. En mettant une CAM suivie d'une RAM, on a un encodeur dont l'entrée est envoyée à un décodeur. [[File:Cache totalement associatif naif.png|centre|vignette|upright=3|Cache totalement associatif naif]] Or, le décodeur réalise l'opération inverse de l'encodeur, ce qui fait que mettre les deux composants à la suite ne sert à rien. On peut donc retirer l'encodeur et le décodeur, et envoyer directement les résultats des comparateurs sur les entrées de commande du plan mémoire de la RAM. [[File:Cache totalement associatif optimisé.png|centre|vignette|upright=2|Cache totalement associatif optimisé]] Avec cette méthode, les circuits du cache ressemblent à ce qui illustré ci-dessous. Le tag est envoyé à chaque ligne de cache. Le tag envoyé est alors comparé avec le Tag contenu dans chaque ligne de cache, comme c'est le cas sur les mémoires associatives. Si une ligne de cache matche avec le tag envoyé en entrée, la ligne pour laquelle il y a eu une égalité est alors connectée sur les lignes de bit (''bitlines''). Cela est réalisé par un circuit commandé par le comparateur de la ligne de cache. Il ne reste plus qu'à sélectionner la portion de la ligne de cache qui nous intéresse, grâce à un paquet de multiplexeurs. Cela permet d'effectuer une lecture ou écriture, mais il faut aussi préciser si il y a eu un défaut de cache ou un succès. Un succès de cache a lieu quand au moins des comparaisons est positive, alors que c'est un défaut de cache sinon. En clair, détecter un succès de cache demande juste de connecter une porte OU à plusieurs entrées à tous les comparateurs. [[File:Organisation générale d'un cache totalement associatif.png|centre|vignette|upright=2|Organisation générale d'un cache totalement associatif.]] ===Les caches directement adressés=== Les caches directement adressés peuvent être vus comme un cache totalement associatif auquel on aurait ajouté des restrictions assez drastiques. Plus haut, on a vu qu'un cache totalement adressé est équivalent à la combinaison d'une CAM avec une RAM. La mémoire CAM prend en entrée un Tag et traduit celui-ci en une adresse qui commande la mémoire RAM interne au cache. Dans ce qui suit, l'adresse interne au cache sera appelé l''''indice''' pour éviter toute confusion. [[File:Cache hash table - 2.png|centre|vignette|upright=2|Fonctionnement interne du cache, expliquée sous forme abstraite, en utilisant la notion d'indice interne au cache.]] Les caches directement adressés cherchent à remplacer la mémoire CAM par un circuit combinatoire. Ce circuit traduit le Tag en indice, mais est beaucoup plus simple qu'une mémoire CAM. Mais qui dit circuit plus simple dit circuit plus limité. Un circuit combinatoire n'est pas aussi versatile que ce qui est permis avec une mémoire CAM. En conséquence, une restriction majeure apparait : toute adresse mémoire est associée dans une ligne de cache prédéfinie, toujours la même. L'association entre ligne de cache et adresse mémoire est faite par le circuit combinatoire, et ne peut pas changer. Les concepteurs de caches s'arrangent pour que des adresses consécutives en mémoire RAM occupent des lignes de cache consécutives, par souci de simplicité. Tout se passe comme suit la mémoire RAM était découpés en blocs de la même taille que le cache. La première adresse du bloc est associée à la première ligne de cache (celle d'indice 0), la seconde adresse est associée à la seconde adresse du_ bloc, et ainsi de suite. Le tout est illustré ci-dessous. [[File:Cache adressé directement.png|centre|vignette|upright=2|Cache adressé directement.]] Avec cette contrainte, le circuit de traduction de l'adresse en adresse mémoire pour la RAM interne au cache est drastiquement simplifié, et disparait même. Une partie de l'adresse mémoire sert à indiquer la position de la donnée dans le cache, le reste de l'adresse sert encode le tag et la position de la donnée dans le ligne de cache. [[File:Cache line.png|centre|vignette|upright=2|Adresse d'une ligne de cache sur un cache adressé directement.]] Un cache directement adressé est conçu avec une RAM, un comparateur, et un paquet de multiplexeurs. En général, la mémoire RAM stocke les lignes de caches complète. Il arrive que l'on utilise deux mémoires RAM : une pour les tags et une pour les données, mais cette technique augmente le nombre de circuits et de portes logiques nécessaires, ce qui réduit la capacité du cache. L'index à lire/écrire est envoyé sur l'entrée d'adresse de la RAM, la RAM réagit en mettant la ligne de cache sur sa sortie de donnée. Sur cette sortie, un comparateur compare le tag de la ligne de cache lue avec le tag de l'adresse à lire ou écrire. On saura alors si on doit faire face à un défaut de cache. Ensuite, un multiplexeur récupère la donnée à lire/écrire. [[File:Direct mapped cache - french.png|centre|vignette|upright=2|Cache directement adressé.]] L'accès à un cache directement adressé a l'avantage d'être très rapide vu qu'il suffit de vérifier une seule ligne de cache : celle prédéfinie. Mais ces caches ne sont cependant pas sans défauts. Vu que le cache est plus petit que la mémoire, certaines adresses mémoires se partagent la même ligne de cache. Si le processeur a besoin d’accéder fréquemment à ces adresses, chaque accès à une adresse supprimera l'autre du cache : tout accès à l'ancienne adresse se soldera par un défaut de cache. Ce genre de défauts de cache causés par le fait que deux adresses mémoires ne peuvent utiliser la même ligne de cache s'appelle un '''défaut par conflit''' (''conflict miss''). Les défauts par conflit n'existent pas sur les caches totalement associatifs. En conséquence, le taux de succès des caches directement adressés est assez faible comparé aux autres caches. [[File:Cache Block Basic Conflict.svg|centre|vignette|upright=1.5|Exemple de ''Conflict Miss''.]] ===Les caches associatifs par voie=== Les caches associatifs par voie sont un compromis entre les caches directement adressés et les caches totalement associatifs. Pour simplifier, ces caches sont composés de plusieurs caches directement adressés accessibles en parallèle, chaque cache/RAM étant appelé une '''voie'''. Avec ces caches, toute adresse mémoire en RAM est associée à une ligne de cache dans chaque voie. [[File:Cache associatif par voie.png|centre|vignette|upright=2|Cache associatif par voie.]] Le schéma ci-dessous compare un cache directement adressé et un cache associatif à deux voies. On voit que chaque adresse est associée à une ligne de cache bien précise avec un cache directement dressé, et à deux lignes de cache avec un cache associatif à deux voies. L'adresse sera associée à 4 lignes de cache sur un cache associatif à 4 voies, à 8 lignes pour un cache à 8 voies, etc. L'ensemble des lignes de cache associées à une adresse est appelé un '''ensemble'''. [[File:Cache Fill.svg|centre|vignette|upright=2|Comparaison entre un cache directement adressé et un cache associatif à deux voies.]] Sur ces caches, toute adresse est découpée en trois parties : un tag, un index, et un décalage, comme sur les caches directement adressés. Comme vous pouvez le voir, l'organisation est identique à celle d'un cache totalement associatif, à part que chaque ensemble tag-ligne de cache est remplacé par une mémoire RAM qui en contient plusieurs. [[File:Implémentation d'un cache associatif par voie.png|centre|vignette|upright=2|Implémentation d'un cache associatif par voie.]] Le risque de conflits d'accès au cache est donc réduit sur un cache associatif à plusieurs voies, et il est d'autant plus réduit que le cache a de voies. Par contre, leur conception interne fait qu'ils ont un temps d'accès légèrement élevé que les caches directement adressés. Les caches associatifs par voie ont donc un taux de succès et un temps d'accès intermédiaire, situé entre les caches directement adressés et totalement associatifs. Ils sont une sorte de compromis entre réduction des défaut par conflits d'accès au cache et temps d'accès, et complexité des circuits. ==Les optimisations des caches associatifs par voie== Les caches partiellement associatifs regroupent les caches associatifs par voie et directement adressés, ainsi que leurs variantes. En clair : tous les caches qui ne sont pas totalement associatifs. Ils peuvent être optimisés de nombreuses manières, que ce soit pour gagner en performance ou pour économiser de l’énergie. Dans cette section, nous allons voir quelles sont ces optimisations. ===Les caches pseudo-associatifs=== Les caches adressés par voie contiennent une mémoire SRAM par voie. En théorie, les voies sont accédées en parallèles, en même temps, afin de voir si l'on a un succès de cache ou un défaut. Les '''caches pseudo-associatifs''' sont identiques aux caches associatifs par voie, si ce n'est qu'ils vérifient chaque voie une par une. Ils ont été utilisés sur des processeurs commerciaux, un exemple étant l'IBM 370. Là encore, on perd en performance pour gagner en consommation d'énergie. Le temps d'accès dans le meilleur des cas est plus faible pour les caches pseudo-associatifs, mais le pire des cas teste tous les caches avant de tomber sur le bon. Les performances sont donc réduites. Mais la consommation énergétique est meilleure, vu qu'on ne vérifie pas forcément toutes les voies en parallèle. On teste la première voie, éventuellement la seconde, peut-être la troisième, etc. Mais dans le cas général, on ne teste qu'une partie des voies, pas toutes, ce qui donne un gain en termes d'énergie. L'implémentation de caches de ce genre demande que l'on parcoure les voies une par une, en commençant de la première jusqu'à la dernière. Pour cela, un simple compteur suffit. Suivant la valeur du compteur, la voie associée est activée puis accédée. Toute la complexité revient à ajouter un circuit qui prend la valeur du compteur, et active la voie associée, lance un accès mémoire dessus. Vu que les voies sont chacune des caches ''direct mapped'', il suffit pour cela de geler les entrées d'adresse, soit en les déconnectant, soit en utilisant du ''clock gating'' ou de l'évaluation gardée. Les détails d'implémentation, non-cités ici, varient selon le cache. ===La prédiction de voie=== Pour réduire le temps d'accès des caches pseudo-associatifs, certains chercheurs ont inventé la '''prédiction de voie''', qui consiste à faire des paris sur la prochaine voie accédée. L'idée est d'accéder à la voie qui contient la donnée voulue du premier coup, en lisant celle-ci en priorité. Dans son implémentation la plus simple, le cache reste un cache pseudo-associatif. Lors d'un accès au cache, les voies sont toutes parcoures une par une. Par contre, les voies ne sont donc pas parcourues de la première vers la dernière, mais dans un ordre différent. Cette technique permet de mettre en veille les voies sur lesquels le processeur n'a pas parié, ce qui permet de diminuer la consommation énergétique du processeur. C'est plus efficace que d'aller lire plusieurs données dans des voies différentes et de n'en garder qu'une. L'implémentation est assez simple : il suffit d'ajouter un circuit de prédiction de voie,relié au compteur de voie. Une amélioration de la technique fait fonctionner le cache comme un intermédiaire entre cache pseudo-associatif et associatif par voies. L'idée est de chercher la voie prédite en premier, puis de chercher dans toutes les voies en parallèle en cas de défaut de cache. Au lieu d'attendre que les comparaisons de tags donnent leur résultat, le processeur sélectionne automatiquement une voie et configure les multiplexeurs à l'avance. Si le processeur ne se trompe pas, le processeur accède à la donnée plus tôt que prévu. S'il se trompe, le processeur annule la lecture effectuée en avance et recommence en faisant un accès en parallèle aux autres voies. Le compromis entre performance et consommation d'énergie est alors différent. On économise de l'énergie par rapport à un cache associatif par voie, au prix d'une petite perte de performance (doublement des temps d'accès). Mais par rapport à un cache pseudo-associatif, l'économie d'énergie est bien moindre, au prix d'un gain en performance assez manifeste. Prédire quelle voie sera la bonne est assez simple. En vertu du principe de localité, les accès futurs ont des chances de tomber dans les voies les plus fréquemment utilisées ou dans celle plus récemment utilisée. Il suffit de retenir la voie la plus récemment accédée dans un registre, qui sera utilisée comme prédiction. Pour vérifier que la prédiction est correcte, il suffit de comparer le registre et le résultat obtenu après vérification des tags. Cependant, on peut complexifier l'implémentation pour prendre en compte l'adresse à lire/écrire, l'instruction à l'origine de l'accès mémoire ou tout autre paramètre utile. Par exemple, des instructions différentes ont tendance à aller chercher leurs données dans des ensembles différents et la voie à choisir n'est pas la même. Pour cela, il suffit d'utiliser un cache pour stocker la correspondance instruction - voie. Pour plus de simplicité, la mémoire cache des prédictions est parfois remplacée par une RAM, qui est adressée : * soit par le program counter de l'instruction à l'origine de l'accès (en réalité, seulement quelques bits de poids faible de l'adresse) ; * soit par l'adresse à accéder (là encore, quelques bits de poids faible) ; * soit (pour les modes d'adressage qui utilisent un registre de base et un décalage) par un XOR entre les bits de poids faible de l'adresse de base et le décalage ; * soit par autre chose. ===La mise en veille sélective des voies=== Les caches associatifs ont tendance à utiliser beaucoup d'énergie, même quand on n'y accède pas. Aussi, certains processeurs détectent quand le cache est peu utilisé et en profitent pour mettre en veille les voies inutilisées. Vous vous demandez certainement ce qui se passe quand une donnée à lire/écrire est dans une voie désactivée. La réponse est que le cache détecte cette situation, car elle déclenche un succès de cache. Les ''tags'' ne sont en effet pas désactivés, seules les données sont mises en veille. L'implémentation est plus simple sur les caches qui séparent les tags et les données dans deux RAM différentes. Cette optimisation marche surtout sur les gros caches, qui ont des chances d'avoir une portion significative d’inutilisée (pas assez de données pour les remplir), donc généralement les caches L3/L4. Par exemple, les processeurs d'Intel de microarchitecture Ivy Bridge disposent d'un cache de 8 mébioctets à 16 voies, qu'ils peuvent faire passer à 512 kibioctets si le besoin s'en fait sentir. Quand ces processeurs détectent une faible activité, ils mettent en veille 14 voies et n'en gardent que 2 d'actives. Évidemment, les 14 voies sont vidées avant d'être mises en veille, afin qu'une aucune donnée ne soit perdue. ===Les caches ''skew-associative''=== Vous aurez remarqué que dans une voie, les lignes sont accédées en adressage direct : les défauts par conflit sont possibles sur un cache associatif par voie. Pour éviter cela, certains chercheurs ont créé des '''caches ''skew associative''''' (ou associatifs à biais). Pour faire simple, les index des lignes de cache subissent un petit traitement avant d'être utilisés. Le traitement en question est différent suivant la voie de destination, histoire que deux adresses mémoires avec des index identiques donnent des index différents après traitement. Le traitement en question est souvent une permutation des bits de l'index, qui est différente suivant la voie prise, ou un simple XOR avec un nombre qui dépend de la voie. [[File:Implémentation d'un cache skew associative.jpg|centre|vignette|upright=2|Implémentation d'un cache skew associative.]] ==Les caches splittés (''phased caches'')== Dans cette section, nous allons voir les '''caches splittés''' (''phased caches''), qui sont une variante des caches ''direct-mapped'', dans lequel le cache est accédé en deux étapes consécutives. Il ne s'agit pas des caches pipelinés, que nous verrons dans le chapitre sur les processeurs pipélinés, mais laissons cela à plus tard. Il est possible d'appliquer la même méthode sur un cache associatif par voie, mais il y a des méthodes plus simples, qui permettent là aussi d’accéder au cache en plusieurs étapes consécutives. L'idée est de scinder le cache en deux : une mémoire pour les tags, une autre pour les données de la ligne de cache. Les bits de contrôle peuvent être mis dans l'une ou l'autre SRAM, mais ils sont souvent mis dans la RAM pour les tags. En faisant cela, quelques optimisations deviennent possibles, afin de réduire la consommation énergétique en contrepartie d'une perte de performance. La technique s'implémente différemment pour les caches totalement associatifs et partiellement associatifs. Les caches totalement associatifs splittés sont ceux formés en combinant un cache associatif avec une CAM et une RAM combinée. On envoie l'adresse à lire/écrire à la mémoire associative, elle répond en envoyant une adresse à la mémoire RAM. L'accès se fait donc en deux temps, avec l'adresse dans la RAM comme intermédiaire. Il est possible de séparer physiquement les deux étapes en insérant un registre entre la CAM et la RAM, ce qui permet aussi de pipeliner l'accès. Mais c'est rarement fait en pratique, car le cout en circuit d'une mémoire CAM est trop important. L'équivalent pour un cache totalement associatif optimisé, sans CAM et RAM séparée, est trop gourmande en interconnexions pour être implémentée. Les caches totalement associatifs splittés sont donc très rares, l'auteur ne connait aucun exemple de processeur avec un tel cache. Il existe une technique équivalente pour les caches ''direct-mapped'', mais elle demande une certaine modification du cache. Dans les caches ''direct-mapped'' non-splittés, on trouve une mémoire SRAM dont chaque mot mémoire contient une ligne de cache entière, tag inclus. Dans leurs versions splittés, la SRAM est séparée en deux : une pour les tags, une autre pour les données. Précisons qu'il s'agit bien de deux mémoires SRAM adressables. L'adresse à laquelle accéder est envoyée à la SRAM des tags, puis ensuite à la SRAM des données si besoin. L'idée est d’accéder aux tags pour déterminer s'il y a un succès de cache ou un défaut, et ensuite d'accéder aux données. On n’accède pas aux données en parallèle des tags. Faire cela est évidemment plus lent. En cas de défaut de cache, le temps d'accès est similaire : le tag ne correspond pas, on n'accède pas à la SRAM pour les données. Par contre, vu qu'on n'a pas activé la SRAM pour les données, on économise un peu d'énergie, ce qui réduit la consommation d'énergie. En cas de succès de cache, on accède à la SRAM pour les tags, puis à celle pour les données. Pas d'économie d'énergie à l'horizon, sans compter que le temps d'accès augmente : on accède au cache en deux étapes au lieu de faire les deux accès en parallèle. [[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]] Précisons cependant que ce design peut avoir deux avantages en termes de performance. Premièrement, le temps d'accès au cache est légèrement amélioré en cas de défaut de cache. En effet, la SRAM des tags est assez petite, idem pour celle des données. Leur temps d'accès est donc plus faible que pour une grosse SRAM contenant données et tags. Le gain en temps d'accès est donc un avantage, qui ne se manifeste surtout en cas de défaut de cache. Un autre avantage est que l'accès au cache se pipeline plus facilement, ce qui fait qu'on peut effectuer plusieurs accès simultanés au cache. Mais nous verrons cela dans quelques chapitres. ===Le contrôleur de cache 82385 pour les CPU Intel 386=== Il est important de noter que la séparation entre tags et RAM peut être telle que les deux ne sont pas sur la même puce de silicium ! Voire que les deux sont séparés du processeur ! C'était le cas quand les mémoires caches ont été introduites sur les processeurs grand public, notamment sur les premiers processeurs Intel. La miniaturisation n'avait pas avancé au point où placer un cache dans le processeur était possible. Sur le processeur 386 d'Intel, le cache était un cache splitté, séparé du processeur. Concrètement, le processeur i386 était couplé à un contrôleur de cache Intel 82385 et une mémoire SRAM. Le 82385 contenait les ''tags'' et les bits de contrôle, la SRAM contenait les données, les lignes de cache. Un point important est que les lignes de cache faisaient seulement 32 bits/4 octets, pas plus ! On était loin des lignes de cache actuelles, faisant 64 octets/512 bits. Mais c'était beaucoup plus pratique, vu que le bus système faisait 32 bits de large, idem pour l'interface avec le processeur. Pour intégrer un cache facultatifs (certains ordinateurs 'en passaient). Le schéma ci-dessous montre comment le cache s'intégrait avec le bus système. Pour le bus de commande, le cache servait d'intermédiaire : il recevait les commandes et et les filtrait suivant les succès/défauts de cache. En cas de succès de cache, les commandes de lecture n'étaient pas envoyées à la mémoire RAM. Les adresses étaient transmises à la fois au cache et au bus système (avec un registre entre le bus système et le processeur). Le bus de donnée était lui connecté à la mémoire SRAM et au processeur, avec des MUX/DEMUX pour faire le choix de la source des lectures. [[File:Controleur de cache 82385 pour l'Intel 386.png|centre|vignette|upright=2.5|Contrôleur de cache 82385 pour l'Intel 386]] Le 82385 surveillait ce qui se passait sur le bus et répondait à la place de la RAM pour certaines lectures. C'était un intermédiaire assez passif, qui se contenait de répondre aux succès et défauts en lecture. Le cache était un cache ''write through'' un peu particulier. En cas de succès de cache pour une écriture, le cache met à jour sa ligne de cache et propage l'écriture en mémoire RAM. Par contre, si une écriture fait un défaut de cache, la donnée n'est pas écrite dans le cache. Le seul moyen pour copier une donnée dans le cache était un défaut pour une lecture. Le 82385 pouvait commander soit un cache ''direct mapped'', soit associatif à deux voies. Le choix entre les deux était le fait d'une entrée : la mettre à 0 indiquait un cache ''direct mapped'', la mettre à 1 forçait un cache à deux voies. La différence entre les deux est que le 82385 était relié à une mémoire SRAM avec un cache ''direct mapped'', deux SRAM pour deux voies. Pour avoir un cache associatif à deux voies, le 82385 devrait gérer deux signaux ''chip select'' pour activer chaque SRAM/voie suivant les besoins. Il avait précisément quatre signaux CS : deux par SRAM, un pour les lectures, un pour les écritures. Notons que les SRAM utilisées devaient être des SRAM avec un bus de données de 32 bits, des SRAM 16 ou 8 bits ne fonctionnaient pas. Le 82385 rajoutait 4 sorties, pour masquer chaque octet dans ces 32 bits. [[File:Interface entre le 82385 et la SRAM du cache.png|centre|vignette|upright=2|Interface entre le 82385 et la SRAM du cache]] Il gérait aussi les accès mémoire non-cacheable, à savoir des accès mémoire qui ne doivent pas être pris en compte par le cache. Il considérait certains accès mémoire comme "à ne pas cacher". Notamment, les accès mémoire à une entré-esortie ne sont pas cachés. Pour rappel, le processeur utilisait un espace d'adressage séparé pour les entrées-sorties, et utilisait donc un bit IO, qui était utilisé par le 82385 pour savoir si l'accès mémoire doit être caché ou non. Il en est de même pour les accès ayant lieu lors d'une interruption, qui ne passent pas par le cache. Mais au-delà de cette inhibition automatique du cache, le 82385 avait une entrée NCA (''Non Cacheable Access'') : le cache était "désactivé" quand cette entrée était à 1. C'est un peu une sorte de ''chip select'' pour le 82385, limitée aux accès mémoire. Cette entrée permettait de programmer des intervalles d'adresse auxquels ne pas répondre, en utilisant des circuits de décodage d'adresse adaptés. Il avait aussi une entrée X16, qui permettait d'identifier les accès soit à un composant 16 bits. De tels accès ne doivent pas être mis en cache, sans doute parce que cela ne collait pas avec la taille des lignes de cache (32 bits). Et cette entrée permettait d'inhiber ces accès 16 bits d'agir sur le cache, en utilisant le bit du bus de commande adéquat. Le 82385 pouvait être intégré dans un système à deux processeurs, voire plus. Pour cela, chaque processeur avait son propre 82385 et sa SRAM rien qu'à lui. Il n'y avait pas de cache partagé entre les deux processeurs. Par contre, les deux caches étaient reliés au même bus système. Pour qu'ils ne se marchent pas sur les pieds, il y avait des circuits d'arbitrage pour gérer l'accès au bus. Un des deux 82385 était mis en mode maitre, l'autre était en mode esclave. Le 82385 maitre pouvait prendre le contrôle du bus, le 82385 esclave devait demander l'autorisation au premier pour accéder au bus système. Le 82385 gérait une forme limitée de cohérence des caches par invalidation. Dès que le 82385 détectait une prise de contrôle du bus par autre chose que le processeur, il surveillait les adresses transmises sur le bus. En cas de succès de cache, la ligne de cache associée était invalidée. Au-delà de ca, le 82385 avait une entrée FLUSH, qui ordonnait une invalidation totale du cache. Si cette entrée est mise à 1, toutes les lignes de cache sont invalidées. Les ''tags'' sont marqués comme invalides, mais les lignes de cache elles-mêmes ne sont pas touchées. ===L'exemple des processeurs Intel de microarchitecture ''Broadwell''=== Un autre exemple est celui du cache L4 des processeurs Broadwell et de quelques processeurs séparés. Ces processeurs ont une organisation en ''chiplet'' où le processeur incorpore plusieurs puces séparées : une puce pour le processeur proprement dit, une puce nommée ''Crystal Well'' pour le cache L4, et une puce IO pour la communication avec la RAM et la carte mère. Le processeur incorporait un cache L4 de 128 mébioctets, composé de mémoire eDRAM, qui était dispersé entre ''Crystal Well'' et les autres puces. Les données du cache L4 étaient dans ''Crystal Well'', alors que les Tags étaient soit dans le processeur lui-même, soit dans la puce IO ! La puce ''Crystal Well'' était une mémoire DRAM adressable tout ce qu'il y a de plus basique, avec cependant quelques optimisations notables. Par exemple, elle avait deux bus séparés pour l'écriture et la lecture. De plus, elle avait une organisation interne avec 128 banques, contre moins d'une dizaine pour la DDR de l'époque et environ 32 banques pour la DDR5 moderne. Elle contenait aussi quelques circuits pour gérer son rôle de mémoire cache, mais rien en ce qui concerne la gestion des tags eux-mêmes. Sur les processeurs de microarchitecture ''Broadwell'', les tags étaient placés dans le CPU et précisément dans le cache L3. À chaque accès mémoire au cache L3, les tags du cache L4 étaient consultés en parallèle. De fait, l'accès au cache L4 était assez rapide, malgré le fait que les données étaient dans une puce à part. Ajoutons à cela que le processeur et ''Crystal Well'' n'avaient pas la même finesse de gravure ni la même technologie de fabrication. Les tags étaient implémentés avec de la SRAM contre la DRAM pour les données, ce qui fait que la consultation des tags était plus rapide que l'accès aux données. Par la suite, dans certains CPU de microarchitecture ''skylake'', les tags ont été déplacés en-dehors du processeur pour finir dans le contrôleur mémoire. En faisant cela, le cache L4 pouvait être utilisé par autre chose que le processeur, et notamment par la carte graphique intégrée au CPU. Avec ''broadwell'', le fait que les tags étaient consultés en cas d'accès au L3 empêchait au GPU intégré de consulter le cache L4. Mais en déplaçant les tags dans le contrôleur mémoire, ce n'est plus le cas vu que la carte graphique a aussi accès au bus mémoire. Par contre, le temps d'accès augmente comparé à la solution précédente. On n'accède pas aux tags du L4 en parallèle du L3 : à la place, il faut consulter les tags du L3, détecter un défaut de cache L3, et ensuite accèder aux tags. ===Les caches RAM-configurables=== Un autre avantage des caches splittés est qu'on peut les modifier pour servir à la fois de mémoire cache, mais aussi de ''local store'', de mémoire RAM de petite taille. Le fonctionnement est assez simple à comprendre. Lors d'un accès au cache, on accède aux tags, puis à la RAM interne au cache. Lors d'un accès au ''local store'', on contourne l'accès au tags et on accède à la RAM interne au cache directement. Il s'agit de la technique du '''cache RAM-configurable''. L'usage de cache RAM-configurable est fréquent sur les cartes graphiques récentes, qui incorporent un ou plusieurs processeurs multicoeurs, dont le cache L1 de données est un cache RAM-configurable. [[File:Hydride cache - local store.png|centre|vignette|upright=2.0|Hydride cache - local store]] ===La compression de cache=== Une autre optimisation permise par les ''phased caches'' est l'implémentation de techniques de '''compression de cache''', qui visent à compresser des lignes de cache. L'intérêt est qu'on peut stocker plus de données dans le cache, à capacité égale. L'inconvénient est qu'on doit compresser/décompresser les lignes de cache, ce qui demande un circuit en plus et allonge les temps d'accès. En effet, le temps mis pour compresser/décompresser une ligne de cache s'ajoute au temps d'accès. Aussi, la compression de cache sert surtout pour les caches de bas niveau dans la hiérarchie mémoire, les gros caches aux temps d'accès assez longs. Une première technique, assez simple à implémenter et peu couteuse en circuit, est celle de la '''compression des lignes de cache nulles'''. Elle compresse uniquement les lignes de cache qui ne contiennent que des zéros. L'idée est qu'on ajoute, dans la mémoire des tags, un bit de contrôle pour chaque ligne de cache appelé le bit ''null''. Il indique si la ligne de cache ne contient que des zéros. Quand on lit une ligne de cache, la mémoire des tags est accédée et on vérifie le bit ''null'' : s'il vaut 1, on n'accède pas à la mémoire cache de données et un multiplexeur envoie un zéro sur le port de lecture. Le bit ''null'' est fixé lors de l'écriture d'une ligne de cache : elle passe dans un comparateur avec zéro relié à la mémoire des tags. La comparaison avec zéro peut se faire en parallèle de l'écriture ou avant (dans ce cas, on n'écrit pas la ligne de cache dans le cache). Les autres techniques de compression de cache permettent de compresser autre chose que des lignes de cache nulles. L'idée est qu'une ligne de cache physique peut par moment mémoriser plusieurs lignes de caches compressées. Par exemple, prenons un cache dont les lignes de cache font 64 octets. Il est possible de compresser deux lignes de cache pour qu'elles fassent chacune 32 octets, et les stocker dans une seule ligne de cache. Les deux lignes de cache auront des tags différents, mais pointeront sur la même ligne de cache physique. Et cela demande d'utiliser un ''phased cache'' dont la mémoire pour les tags est plus grande que la mémoire pour les données. Il n'y a donc plus une bijection entre tags et ligne de cache, mais une relation surjective. Chose qui n'est possible qu'avec un ''phased cache''. De plus, des bits de contrôles associés à chaque ''tag'' indiquent où se trouvent les lignes de cache compressées dans la ligne de cache : est-ce que c'est les 32 octets de poids fort ou de poids faible ? [[File:Compression de cache.png|centre|vignette|upright=2|Compression de cache]] Il ne semble pas que les techniques de compression de cache soient implémentées sur les processeurs modernes. Aucun n'utilise de compression de cache, à ma connaissance. Il faut dire que les techniques connues sont de mauvais compromis : le temps d'accès du cache augmente beaucoup, le cout en circuit pourrait être utilisé pour un cache non-compressé mais plus grand. Et notons que la compression de cache ne marche que si les données peuvent se compresser. Si ce n'est pas le cas, une partie de la mémoire des tags est inutilisée. Une revue de la littérature académique sur la compression de cache est disponible via ce lien, pour les curieux : * [https://inria.hal.science/hal-03285041 Understanding Cache Compression, par Carvalho et Seznec]. ==L'adressage physique ou logique des caches== Le cache utilise les adresses à lire/écrire pour déterminer s'il a une copie de la donnée en son sein. Mais l’interaction entre caches et mémoire virtuelle donne lieu à un petit problème : l'adresse utilisée est-elle une adresse virtuelle/logique ou physique ? La réponse varie suivant le processeur : certains caches utilisent l'adresse virtuelle, tandis que d'autres prennent l'adresse physique. On parle de cache '''virtuellement tagué''' dans le premier cas et de cache '''physiquement tagué''' dans le second. {| |[[File:Cache tagué virtuellement.png|vignette|Cache tagué virtuellement.]] |[[File:Cache tagué physiquement.png|vignette|Cache tagué physiquement.]] |} ===L'accès à un cache physiquement/virtuellement tagué=== La manière d'accéder à un cache dépend de s'il est virtuellement ou physiquement tagué. Il faut utiliser l'adresse virtuelle pour les premiers, physique pour les seconds. Avec un cache virtuellement tagué, l'adresse logique peut être envoyée directement au cache. La MMU ne traduit les adresses que s'il faut accéder à la mémoire RAM. Ces caches sont donc plus rapides. Avec un cache physiquement tagué, le processeur doit traduire l'adresse logique en adresse physique dans la MMU, avant d'accéder au cache. La traduction d'adresse se fait soit en accédant à une table des pages en mémoire RAM, soit en accédant à un cache spécifiquement dédié à accélérer la traduction d'adresse, la TLB (''Translation Lookaside Buffer''). Dans la quasi-totalité des cas, la traduction d'adresse passe par la TLB, ce qui fait qu'elle est raisonnablement rapide. Toujours est-il que chaque accès au cache demande d'accéder à la TLB et de faire la traduction d'adresse avant d'accéder au cache. L'accès est donc plus lent que sur les caches virtuellement tagués, où les accès sont plus directs. [[File:Virtual and Physical addressing.svg|centre|vignette|upright=2|Cache tagué virtuellement versus physiquement tagué.]] ===Les défauts des caches virtuellement tagués=== Les caches physiquement tagués sont moins rapides que les caches virtuellement adressés. Pourtant, les caches virtuellement tagués sont peu fréquents sur les processeurs modernes. Et la raison est assez intéressante : c'est une question d'adresses homonymes et synonymes. ====Les droits d'accès doivent être vérifiés lors d'un accès au cache==== Un premier problème est que la protection mémoire est compliquée avec de tels caches. Rappelons que certaines portions de mémoire sont accessibles seulement en lecture, ou sont interdites en écriture, sont inexécutables, etc. Ces droits d'accès sont gérés par la MMU, qui vérifie pour chaque accès mémoire que l'accès est autorisé. En bypassant la MMU, l'accès au cache virtuellement tagué ne permet pas de faire ces vérifications. Il est possible de charger une donnée en lecture seule dans le cache, mais d'y faire des accès en écriture pour les accès ultérieurs. Les solutions à cela sont multiples. La première consiste à consulter la MMU en parallèle de l'accès au cache. L'accès au cache est alors réalisé de manière spéculative, et est ensuite confirmé/annulé une fois que la MMU a rendu son verdict. Les performances du cache restent alors les mêmes : l'accès à la MMU se fait en parallèle de l'accès au cache, pas avant. Une autre solution est d'ajouter les droits d'accès en question dans la ligne de cache, dans les bits de contrôle situés après le Tag. Chaque accès au cache récupère ces bits de contrôle et vérifie si l'accès est autorisé. L'inconvénient est que les lignes de cache deviennent plus longues, les droits d'accès sont dupliqués entre MMU et cache. Mais si le budget en transistor suit, ce n'est rien d'insurmontable. ====Les adresses homonymes perturbent la gestion du cache==== Pour rappel, une adresse logique homonyme correspond à plusieurs adresses physiques différentes. Elles surviennent quand chaque programme a son propre espace d'adressage. Dans ce cas, une adresse logique correspondra à une adresse physique différente par programme.Une autre manière de voir les choses est qu'il y a en réalité deux adresses homonymes, qui ont la même valeur, mais appartiennent à des espaces d'adressage différentes. Et c'est cette seconde interprétation que nous allons utiliser. Les caches doivent gérer ces adresses homonymes et faire en sorte que la lecture/écriture d'une adresse homonyme se fasse à la bonne adresse physique, dans la bonne ligne de cache. Et autant un cache physiquement tagué n'a aucun problème avec ça, vu qu'il ne gère que des adresses physiques, autant des problèmes surviennent avec les caches virtuellement tagués. Le problème est que les caches virtuellement tagués doivent faire la différence entre deux adresses homonymes de même valeur. Pour corriger ces problèmes, il existe deux grandes méthodes. La première méthode est simple : '''vider les caches''' en changeant de programme. Leur contenu est rapatrié en mémoire RAM, puis les caches sont remis à zéro. Le vidage du cache recopie les lignes de cache ''dirty'' (modifiées) en RAM, puis efface/invalide tout le cache. C'est à cela que servent les instructions ''clean'' et d'invalidation vues plus haut, elles ont été inventées pour cette situation précise. Lorsque le système d'exploitation déclenche une commutation de contexte, à savoir qu'il change le programme en cours d'exécution, le processeur vide tous les caches du processeur. Les interruptions font la même chose, elles vide tous les caches du processeur. Une seconde méthode numérote chaque programme en cours d'exécution, chaque processus. Le numéro attribué est spécifique à chaque processus, ce qui fait qu'il est appelé un '''identifiant de processus CPU'''. Le processeur mémorise l'identifiant du programme en cours d'exécution dans un registre dédié. L'identifiant de processus CPU est utilisé lors des accès mémoire. Chaque ligne de cache contient le numéro de l'espace d'adressage associé, dans son ''tag''. Lors de chaque accès mémoire, l'ID du registre est comparé à l'ID de la ligne de cache accédée, pour vérifier que l'accès mémoire accède à la bonne donnée. Cette méthode n'est pas très économe en termes de transistors. L'usage d'identifiant de processus CPU est clairement meilleure en termes de performance, les commutations de contexte sont plus rapides. Par contre, le budget en transistor est plus important. Un autre défaut de cette méthode est que l'identifiant de processus est généralement codé sur une dizaine de bits, alors que le système d'exploitation utilise des identifiants de processus beaucoup plus larges, de 32 à 64 bits sur les CPU 32/64 bits. L'OS doit gérer la correspondance entre identifiants de processus CPU et ceux de l'OS. Parfois, pour cette raison, les OS n'utilisent pas toujours ce système d'identifiant de processus CPU. ====Les adresses synonymes perturbent aussi la gestion du cache==== La gestion des adresses synonymes est aussi un gros problème sur les caches virtuellement tagués. Pour rappel, il s'agit du cas où des adresses logiques différentes pointent vers la même adresse physique. Typiquement, quand deux programmes se partagent un morceau de mémoire, ce morceau correspondra à des adresses synonymes dans les deux espaces d'adressage. Mais il arrive que l'on ait des adresses synonymes dans le même espace d'adressage, ce n'est pas si rare ! Autant les adresses synonymes ne posent aucun problème avec les caches physiquement tagués, ce n'est pas le cas avec les caches virtuellement adressés. Sur ces caches, deux adresses logiques synonymes vont tomber dans deux lignes de cache différentes. Corriger ce problème demande d'ajouter des circuits annexes pour détecter les adresses synonymes, qui sont vraiment complexes et ont un cout en termes de performance. Aussi, les caches virtuellement tagués sont très peu utilisés sur les processeurs modernes. ===Les caches virtuellement adressés, mais physiquement tagués=== Si les caches physiquement et virtuellement tagués ont des défauts, il existe un intermédiaire qui est un bon compromis entre ces deux extrêmes. Il s'agit des '''caches virtuellement adressés - physiquement tagués''', aussi appelés '''caches pseudo-virtuels'''. Pour comprendre comment ils fonctionnent, précisons que ces caches sont soit des caches ''direct-mapped'', soit des caches associatifs par voie (composés de plusieurs RAM ''direct-mapped'' accédées en parallèle, plusieurs voies). L'accès à ce genre de cache se fait en deux temps : on accède à un ou plusieurs RAM ''direct-mapped'' et on vérifie ensuite les ''Tags'' pour sélectionner la bonne voie. Sur les caches ''direct-mapped'', on n'a qu'une seule RAM ''direct-mapped''. Sur les caches associatifs, on a plusieurs RAM ''direct-mapped'', appelées des voies, qui sont accédées en parallèle. L'accès se fait donc en deux étapes : adresser les RAM ''direct-mapped'' avec un indice, vérifier les ''tags'' avec le reste de l'adresse. Une autre chose à rappeler est que l'adresse logique est composée de deux parties : un numéro de page logique qui indique dans quel page se situe l'adresse, un décalage/''offset'' qui indique la position de l'adresse dans la page. La traduction d'adresse transforme le numéro de page logique en numéro de page physique, mais laisse le décalage intouché. L'idée est d'utiliser le décalage pour adresser les RAM avec le décalage, tandis que le numéro de page sert de ''tag''. Le décalage est découpé en deux lors de l'accès au cache : les bits de poids fort forment l'indice (l'adresse envoyée à la voie), les bits de poids faible donnent la position de l'adresse dans la ligne de cache. L'idée est d'utiliser un numéro de page physique pour les ''tags'', mais d'adresser les voies avec le décalage logique. Les deux servent à des instants différents : vérification des ''tags'' pour l'adresse physique, accès aux voies pour l'adresse logique. Ainsi, le problème des adresses synonymes ou homonymes est résolu par l'utilisation de l'adresse physique pour les tags. Par contre, l'accès au cache est plus rapide, car on utilise l'adresse logique pour la première étape. Le processeur accède à la TLB et récupère l'adresse physique pendant que l'on adresse les voies, les deux sont faits en parallèle, ce qui fait que tout se passe comme si l'accès à la TLB était gratuit. La TLB étant assez rapide comparé au cache, l'adresse physique est disponible quand on doit faire la comparaison avec les ''tags''. [[File:Virtual - Physical - Pseudo Virtual addressing.svg|centre|vignette|upright=2|Adressage pseudo virtuel des caches.]] Il s'agit d'un excellent compromis entre performance et correction des problèmes des adresses synonymes/homonymes. Tous les caches des processeurs haute performance utilisent cette méthode, au moins pour leurs caches L1. Les caches L2 tendent à utiliser des caches physiquement adressés, pour lesquels la latence d'accès est suffisante pour qu'on accède à la TLB en amont. La raison est assez simple à expliquer, elle provient d'une contrainte assez précise sur le calcul de l'indice. La conséquence est qu'un cache ''direct-mapped'' ne peut pas dépasser la taille d'une page, soit 4 kibioctets sur les ordinateurs actuels. Sur les caches associatifs, on peut dépasser cette limite en augmentant le nombre de voies, mais la taille maximale d'une voie reste celle d'une page. Cette contrainte n'est pas trop grave sur les caches de petite taille, dont les caches L1. La plupart d'entre eux ont trouvé un compromis idéal avec moins d'une dizaine de voies par cache, chacun de 4 kibioctets, ce qui donne des caches allant de 16 à 64 kibioctets, soit entre 4 et 16 voies. Par contre, un cache de grande taille doit utiliser un grand nombre de voies, ce qui est peu pratique. Aussi, cette technique de caches pseudo-virtuels n'est pas toujours appliquée sur les caches L2, qui sont physiquement adressés. Il faut dire qu'on accède au cache L2 lors d'un défaut dans le cache L1, et l'adresse physique est disponible à ce moment-là, elle a déjà été récupérée lors de l'accès au cache L1. On peut donc l'utiliser pour adresser le cache L2 sans perte de performance. ==Le remplacement des lignes de cache== Lorsqu'un cache est rempli et qu'on charge une nouvelle donnée dedans, il faut faire de la place pour cette dernière. Dans le cas d'un cache directement adressé, il n'y a rien à faire vu que la ligne de cache à évincer est déterminée lors de la conception du cache. Mais pour les autres caches, la donnée peut aller dans n'importe quelle ligne ou voie. Or, le choix des données à rapatrier en RAM doit être le plus judicieux possible : on doit virer de préférence des données inutiles. Rapatrier une donnée qui sera surement utilisée sous peu est inutile, et il vaudrait mieux supprimer des données qui ne serviront plus ou alors dans longtemps. Il existe différents algorithmes spécialement dédiés à résoudre ce problème efficacement, directement câblés dans les unités de gestion du cache. Certains sont vraiment très complexes, aussi je vais vous présenter quelques algorithmes particulièrement simples. Mais avant de voir ces algorithmes, il faut absolument que je vous parle d'une chose très importante. Quel que soit l'algorithme en question, il choisit la ligne de cache à évincer et recopie son contenu dans la RAM. Ce qui demande d'identifier et de sélectionner une ligne de cache parmi toutes les autres. Pour cela, le circuit de remplacement attribue une adresse chaque ligne de cache ! Vous avez bien vu : chaque ligne de cache est numérotée par une adresse, interne au cache. ===Le remplacement aléatoire=== Premier algorithme : la donnée effacée du cache est choisie au hasard ! C'est contre-intuitif, mais cet algorithme donne des résultats assez honorables, en plus d'utiliser très peu de portes logiques (un générateur de nombres pseudo-aléatoire est un circuit assez simple). Généralement, les défauts de cache sont séparés par un nombre assez important et irrégulier de cycles d'horloge. Dans ces conditions, cette technique donne un bon résultat. ===FIFO : first in, first out=== Avec l'algorithme FIFO, la donnée effacée du cache est la plus ancienne, celle chargée dans le cache avant les autres. Cet algorithme est très simple à implémenter en circuit, concevoir une mémoire de type FIFO n'étant pas très compliqué, comme on l’a vu dans le chapitre dédié à ce type de mémoires. Et on peut dire que dans le cas d'un cache, l'implémentation est encore plus simple et se contente d'un seul registre/compteur. Typiquement, il suffit d'ajouter un registre qui mémorise où se situe la donnée la plus récente. Toute insertion d'une nouvelle donnée se fait à l'adresse suivante, ce qui demande juste d'incrémenter le registre avant d'utiliser son contenu pour l'accès mémoire. [[File:Algorithme FIFO de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme FIFO de remplacement des lignes de cache.]] Cet algorithme possède une petite particularité sur les caches associatifs par voie : en augmentant le nombre d'ensembles, les performances peuvent se dégrader : c'est ce qu'on appelle l''''anomalie de Bélády'''. ===MRU : most recently used=== Avec l'algorithme MRU, la donnée remplacée est celle qui a été utilisée le plus récemment. Cet algorithme s'implémente simplement avec un registre, dans lequel on place le numéro de la dernière ligne de cache utilisée. Cet algorithme de remplacement est très utile quand un programme traverse des tableaux du premier élément jusqu'au dernier : les données du tableau sont rarement réutilisées, rendant le cache inutile. Il est prouvé que dans ces conditions, l'algorithme MRU est optimal. Mais dans toutes les autres conditions, cet algorithme a des performances assez misérables. ===LFU : least frequently used=== Avec l'algorithme LFU, la donnée supprimée est celle qui est utilisée le moins fréquemment. Cet algorithme s'implémente en associant un compteur à chaque ligne de cache, qui est incrémenté à chaque accès mémoire. La ligne la moins récemment utilisée est celle dont le compteur associé a la plus petite valeur. Implémenter cet algorithme prend pas mal de transistors, car il faut rajouter autant de compteurs qu'il y a de lignes de cache, en plus d'un circuit pour comparer les compteurs et d'un encodeur. [[File:Algorithme LFU de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme LFU de remplacement des lignes de cache]] ===LRU : least recently used=== Avec l'algorithme LRU, la donnée remplacée est celle qui a été utilisée le moins récemment. Cet algorithme se base sur le principe de localité temporelle, qui stipule qu'une donnée accédée récemment a de fortes chances d'être réutilisée dans un futur proche. Et inversement, la donnée la moins récemment utilisée du cache est celle qui a le plus de chance de ne servir à rien dans le futur. Autant la supprimer en priorité pour faire de la place à des données potentiellement utiles. Implémenter l'algorithme LRU peut se faire de différentes manières, qui ont pour point commun d'enregistrer les accès au cache pour en déduire la ligne la moins récemment accédée. La manière la plus simple demande d'utiliser un compteur pour chaque ligne de mémoire cache, un peu comme le LFU. La différence avec le LFU est que le compteur n'est pas incrémenté lors d'un accès mémoire. À la place, ce compteur est incrémenté régulièrement, chaque incrémentation ayant lieu en même temps pour tous les compteurs. Quand un bloc est chargé dans le cache, ce compteur est mis à zéro. Quand une ligne de cache doit être remplacée, un circuit va vérifier la valeur de tous les compteurs : la ligne LRU (la moins récemment utilisée), est celle dont le compteur a la valeur la plus haute. Le circuit est composé d'un paquet de comparateurs, et d'un encodeur, comme pour l'agorithme LFU. ===Les approximations du LRU=== Implémenter le LRU demande un nombre de transistors proportionnel au carré du nombre de lignes de cache. Autant dire que le LRU devient impraticable sur de gros caches. Ce qui fait que les processeurs modernes implémentent des variantes du LRU, moins couteuses en transistors, qui donnent un résultat approximativement semblable au LRU. En clair, ils ne sélectionnent pas toujours la ligne de cache la moins récemment utilisée, mais une ligne de cache parmi les moins récemment utilisées. Ce n'est pas un problème si grave que cela car les lignes les moins récemment utilisées ont toutes assez peu de chance d'être utilisées dans le futur. Entre choisir de remplacer une ligne qui a 0,5 % de chances d'être utilisée dans le futur et une autre qui a une chance de seulement 1 %, la différence est négligeable en termes de taux de succès. Mais les gains en termes de circuits ou de temps d'accès au cache de ces algorithmes sont très intéressants. L'algorithme le plus simple consiste à couper le cache (ou chaque voie s'il est associatif) en plusieurs sections. L'algorithme détermine la section la moins récemment utilisée, avant de choisir aléatoirement une ligne de cache dans cette section. Pour implémenter cet algorithme, il nous suffit d'un registre qui mémorise le morceau le moins récemment utilisé, et d'un circuit qui choisit aléatoirement une ligne de cache. Cette technique s'adapte particulièrement bien avec des caches associatifs à voies : il suffit d'utiliser autant de morceaux que de voies. Autre algorithme, un peu plus efficace : le '''pseudo-LRU de type M'''. Cet algorithme attribue un bit à chaque ligne de cache, bit qui sert à indiquer de façon approximative si la ligne de cache associée est une candidate pour un remplacement ou non. Il vaut 1 si la ligne n'est pas une candidate pour un remplacement et zéro sinon. Le bit est mis à 1 lorsque la ligne de cache associée est lue ou écrite. Évidemment, au fil du temps, toutes les lignes du cache finiront par avoir leur bit à 1. Lorsque cela arrive, l'algorithme remet tous les bits à zéro, sauf pour la dernière ligne de cache accédée. L'idée derrière cet algorithme est d'encercler la ligne de cache la moins récemment utilisée au fur et à mesure des accès. L'encerclement commence lorsque l'on remet tous les bits associés aux lignes de cache à 0, sauf pour la ligne accédée en dernier. Au fur et à mesure des accès, l'étau se resserre autour de la ligne de cache la moins récemment utilisée. Après un nombre suffisant d'accès, l'algorithme donne une estimation particulièrement fiable. Et comme les remplacements de lignes de cache sont rares comparés aux accès aux lignes, cet algorithme finit par donner une bonne estimation avant qu'on ait besoin d'effectuer un remplacement. Le dernier algorithme d'approximation, le '''PLURt''', se base sur ce qu'on appelle un arbre de décision. Il a besoin de n − 1 bits pour déterminer la ligne LRU. Ces bits doivent être organisés en arbre, comme illustré plus bas. Chacun de ces bits sert à dire : le LRU est à ma droite ou à ma gauche : il est à gauche si je vaux 0, et à droite si je vaux 1. Trouver le LRU se fait en traversant cet arbre, et en interprétant les bits un par un. Au fur et à mesure des lectures, les bits sont mis à jour dans cet arbre, et pointent plus ou moins bien sur le LRU. La mise à jour des bits s'effectue lors des lectures et écritures : quand une ligne est lue ou écrite, elle n'est pas la ligne LRU. Pour l'indiquer, les bits à 1 qui pointent vers la ligne de cache sont mis à 0 lors de la lecture ou écriture. {| |[[File:Organisation des bits avec l'algorithme PLURt.jpg|vignette|Organisation des bits avec l'algorithme PLURt.]] |[[File:Ligne de cache pointée par les bits de l'algorithme.png|vignette|Ligne de cache pointée par les bits de l'algorithme.]] |} ===LRU amélioré=== L'algorithme LRU, ainsi que ses variantes approximatives, sont très efficaces tant que le programme respecte relativement bien la localité temporelle. Par contre, Le LRU se comporte assez mal dans les circonstances ou la localité temporelle est mauvaise mais où la localité spatiale est respectée, le cas le plus emblématique étant le parcours d'un tableau. Pour résoudre ce problème, des variantes du LRU existent. Une variante très connue, l''''algorithme 2Q''', utilise deux caches : un cache FIFO pour les données accédées une seule fois et un second cache LRU. Évidemment, les données lues une seconde fois sont migrées du cache FIFO vers le cache LRU, ce qui n'est pas très pratique. Les processeurs n'utilisent donc pas cette technique, mais celle-ci est utilisée dans les caches de disque dur. D'autres variantes du LRU combinent plusieurs algorithmes à la fois et vont choisir lequel de ces algorithmes est le plus adapté à la situation. Notre cache pourra ainsi détecter s’il vaut mieux utiliser du MRU, du LRU, ou du LFU suivant la situation. ==Les écritures dans le cache : gestion et optimisations== Les écritures se font à une adresse mémoire bien précise, qui peut ou non être chargée dans le cache. Si la donnée à écrire est chargée dans le cache, elle est modifiée directement dans le cache, mais elle ne l'est pas forcément en mémoire RAM. Suivant le processeur, les écritures sont ou non propagées en mémoire RAM. Il existe deux stratégies d'écritures, appelées respectivement le ''write-back'' et le ''write-through''. Avec un cache ''write-back'', si la donnée à mettre à jour est présente dans le cache, on écrit dans celui-ci sans écrire dans la mémoire RAM. Dans ces conditions, une donnée n'est enregistrée en mémoire que si celle-ci quitte le cache, ce qui évite de nombreuses écritures mémoires inutiles. [[File:Cache write-through.png|centre|vignette|upright=2|Cache write-through.]] Avec les caches '''Write-Through''', toute écriture dans le cache est propagée en RAM. Cette stratégie augmente le nombre d'écritures dans la mémoire RAM, ce qui peut saturer le bus reliant le processeur à la mémoire. Les performances de ces caches sont donc légèrement moins bonnes que pour les caches ''write back''. Par contre, ils sont utiles dans les architectures avec plusieurs processeurs, comme nous le verrons dans les chapitres sur les architectures multiprocesseurs. [[File:Cache write-back.png|centre|vignette|upright=2|Cache write-back.]] ===Les caches ''Write-through''=== Sans optimisation particulière, on ne peut écrire dans un cache ''write-through'' pendant qu'une écriture en RAM a lieu en même temps : cela forcerait à effectuer deux écritures simultanées, en comptant celle imposée par l'écriture dans le cache. Pour éviter cela, certains caches ''write-through'' intègrent un '''tampon d’écriture''', qui sert de file d'attente pour les écritures en RAM. C'est une mémoire FIFO dans laquelle on place temporairement les données à écrire en RAM, où elles attendent en attendant que la RAM soit libre. Grâce à lui, le processeur peut écrire dans un cache même si d'autres écritures sont en attente dans le tampon d'écriture. Par souci d'efficacité, des écritures à la même adresse en attente dans le tampon d’écriture sont fusionnées en une seule. Cela fait un peu de place dans le tampon d’écriture, et lui permet d'accumuler plus d'écritures avant de devoir bloquer le cache. Il est aussi possible de fusionner des écritures à adresses consécutives de la mémoire en une seule écriture en rafales. Dans les deux cas, on parle de '''combinaison d'écriture'''. Mais la technique du tampon d'écriture a cependant un léger défaut qui se manifeste dans une situation bien précise : quand le processeur veut lire une donnée en attente dans le tampon d’écriture. La première manière de gérer cette situation est de mettre en attente la lecture tant que la donnée n'a pas été écrite en mémoire RAM. On peut aussi lire la donnée directement dans le tampon d'écriture, cette optimisation portant le nom de '''''store-to-load forwading'''''. Dans tous les cas, il faut détecter le cas où une lecture accède à une donnée dans le tampon d'écriture. À chaque lecture, l'adresse à lire est envoyée au tampon d'écriture, qui vérifie si une écriture en attente se fait à cette adresse. Pour cela, le tampon d’écriture doit être un cache, dont chaque entrée mémorise une écriture. Chaque ligne de cache contient la donnée à écrire, et le tag de la ligne de cache contient l'adresse où écrire la donnée. Notons que cache d'écriture a une politique de remplacement de type FIFO, le tampon d'écriture non-optimisé étant une mémoire FIFO. ===Les caches ''Write-back''=== Les caches ''write-back'' ont beau avoir des performances supérieures à celles des caches ''write-through'', il existe des optimisations qui permettent d'améliorer leurs performances. Ces optimisations consistent à ajouter des caches spécialisés à côté du cache proprement dit. Ces caches permettent de mémoriser des données qui sont éliminées du cache par les algorithmes de remplacement de ligne cache, sans pour autant faire une écriture en RAM. En suivant la procédure habituelle de remplacement des lignes de cache, on doit rapatrier la ligne en RAM avant d'en charger une nouvelle. On peut améliorer la situation en faisant l'inverse : on charge la nouvelle ligne pendant que l'ancienne donnée est rapatriée en RAM. Ainsi, la nouvelle ligne est disponible plus tôt pour le processeur, diminuant son temps d'attente. Pour implémenter cette technique, on doit mémoriser l'ancienne ligne de cache temporairement dans un '''cache d’éviction''' (ou ''write-back buffer''). [[File:Cache d’éviction.png|centre|vignette|upright=2|Cache d’éviction]] Les caches directement adressés ou associatifs par voie possèdent aussi un tampon d’écriture amélioré. Pour limiter les défauts par conflit de ces caches, des scientifiques ont eu l'idée d'insérer un cache pour stocker les données virées du cache. En faisant ainsi, si une donnée est virée du cache, on peut alors la retrouver dans ce cache spécialisé. Ce cache s'appelle le '''cache de victime'''. Ce cache de victime est géré par un algorithme de suppression des lignes de cache de type FIFO. Petit détail : ce cache utilise un tag légèrement plus long que celui du cache directement adressé au-dessus de lui. L'index de la ligne de cache doit en effet être contenu dans le tag du cache de victime, pour bien distinguer deux adresses différentes, qui iraient dans la même ligne du cache juste au-dessus. [[File:Victim Cache Implementation Example.svg|centre|vignette|upright=1|Cache de victime.]] ===La configuration du fonctionnement du cache=== Sur de nombreux processeurs, il est possible de configurer la mémoire cache pour qu'elle fonctionne soit en mode ''write-back'', soit en mode ''write-through''. Pour cela, les processeurs modernes incorporent des '''registres de configuration du cache'''. Le terme ''registre de configuration du cache'' est assez transparent et indique bien quel est leur rôle. Ils configurent comment le cache est utilisé et permettent notamment de configurer le cache pour dire s'il doit fonctionner en mode ''write-back'' ou ''write-through''. Ils permettent aussi d'activer ou de désactiver la combinaison sur écriture. Les registres en question sont configurés soit par le BIOS, soit par le système d'exploitation. Ce sont des registres protégés, que les applications ne peuvent pas configurer, elles n'en ont pas le droit. Typiquement, ils ne sont accessibles en écriture qu'en mode noyau. Sur les processeurs x86, les registres de configuration du cache sont appelés des '''''Memory type range registers''''' (''MTRRs''). Les MTRRs sont assez nombreux, et il y a notamment une différence entre mode réel et protégé. Si vous vous souvenez des chapitres sur le mode d'adressage et la mémoire virtuelle, vous vous souvenez que les processeurs x86 incorporent plusieurs modes de fonctionnement. En mode réel, le processeur ne peut adresser qu'un mébioctet de RAM, avec un système de segmentation particulier. En mode protégé, le processeur peut adresser toute la mémoire et la segmentation fonctionne différemment, quand elle n'est pas simplement désactivée. Les MTRRs sont séparés en deux : ceux pour le mode réel, ceux pour le mode protégé. Les MTRRs fixes sont ceux qui configurent le cache en mode réel, ils étaient utilisés pour gérer l'accès au BIOS, à la mémoire VGA de la carte graphique, et quelques autres accès aux entrées-sorties basiques gérées nativement par le BIOS. Pour le mode protégé, les processeurs au-delà du 386 incorporent des MTRRs variables, qui servent pour les autres entrées-sorties en général, notamment les périphériques PCI, la mémoire vidéo de la carte graphique, et j'en passe. De nos jours, les registres de configuration du cache sont désuets et cette fonctionnalité est gérée directement par la mémoire virtuelle. La table des pages contient, pour chaque page mémoire, des bits de contrôle qui disent si la page mémoire est cacheable ou non. Le contournement de cache est alors géré par le système de mémoire virtuelle, le cache de TLB et tout ce qui va avec. ===L’allocation sur écriture=== Que faire quand une écriture modifie une donnée qui n'est pas dans le cache ? Doit-on écrire la donnée dans le cache, ou non ? Si la donnée est écrite dans le cache, on dit que le cache fait une '''allocation sur l'écriture''' (ou ''write-allocate''). Certains caches effectuent une telle allocation sur écriture, mais d'autres ne le font pas ou du moins pas systématiquement. L’allocation sur écriture peut se décliner en deux sous-catégories : le '''chargement à la demande''' et l''''écriture immédiate'''. Dans le premier cas, on charge la donnée à modifier dans le cache, et on la remplace avec la donnée écrite. Dans l'écriture immédiate, l'écriture a lieu directement dans le cache et la donnée à modifier n'est pas chargée dans le cache. Évidemment, seule une portion de la ligne de cache contient la donnée écrite (valide), et le reste contient des données invalides. Le cache doit savoir quelles sont les portions du cache qui sont valides : cela demande d'utiliser un ''sector cache''. [[File:Write-back with write-allocation.svg|centre|vignette|upright=2|Cache Write-back avec allocation sur écriture.]] Sans allocation sur écriture, l'écriture est transférée directement aux niveaux de cache inférieurs ou à la mémoire si la donnée à modifier n'est pas dans le cache. Certains caches de ce genre utilisent une petite optimisation : lors de toute écriture, ils supposent que l'écriture donnera un succès de cache. Si c'est le cas, la ligne de cache qui contient la donnée est mise à jour avec la donnée à écrire. Mais si ce n'est pas le cas, la ligne de cache est invalidée, et l'écriture est transférée directement à la mémoire ou aux niveaux de cache inférieurs. [[File:Write-through with no-write-allocation.svg|centre|vignette|upright=2|Cache Write-through sans allocation sur écriture.]] ===La cohérence des caches=== Il arrive parfois que la mémoire d'un ordinateur soit mise à jour, sans que les modifications soient répercutées dans les mémoires cache. Dans ce cas, le cache contient une donnée périmée. Or, un processeur doit toujours éviter de se retrouver avec une donnée périmée et doit toujours avoir la valeur correcte dans ses caches : cela s'appelle la '''cohérence des caches'''. Il est possible de se retrouver avec des valeurs périmées dans le cache sur les ordinateurs avec plusieurs processeurs, ou si un périphérique écrit en RAM, les modifications ne sont pas répercutées automatiquement dans les mémoires cache. Pour résoudre ce problème, on peut interdire de charger dans le cache des données stockées dans les zones de la mémoire dédiées aux périphériques. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires cache. Autre solution : utiliser le fait que les périphériques déclenchent une interruption matérielle pour laisser le contrôleur DMA accéder à la mémoire. Dans ce cas, il suffit de vider les caches à chaque interruption matérielle. Le processeur peut le faire automatiquement, ou fournir des instructions pour. ==Le ''cache bypassing'' : contourner le cache== Dans certaines situations, le cache n'est pas utilisé pour certains accès mémoire. Diverses techniques permettent en effet d'effectuer des accès mémoire qui contournent le cache, qui ne passent pas par le cache. Ils sont utilisés quand l'accès en cache fait que des instructions normales ne fonctionnent pas. Par exemple, de tels accès directs à la RAM sont notamment utilisés pour l'implémentation d'instructions atomiques, une classe d'instructions spécifiques utilisées sur les processeurs multicœurs, dont nous parlerons dans plusieurs chapitres. Mais ils sont aussi utilisés pour l'accès aux périphériques, ce que nous allons voir maintenant. ===Accéder aux périphériques demande de contourner le cache=== Pour rappel, un périphérique (au sens d'entrée-sortie) contient des registres d’interfaçage qui ont une adresse au même titre que les cases mémoire. Un périphérique peut à tout instant modifier ses registres d’interfaçage, ce qui se répercute automatiquement dans l'espace d'adressage, mais rien de tout cela n'est transmis au cache. Si les accès aux périphériques passaient par l'intermédiaire du cache, on aurait droit à des problèmes. On aurait encore une fois droit à des problèmes de cohérence des caches. Le problème est géré différemment suivant que l'on utilise un espace d'adressage séparé ou des entrées-sorties mappées en mémoire. La solution est que les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache. Cela demande d'adapter le cache et le processeur. L'implémentation exacte dépend de comment sont adressés les périphériques. Pour rappel, il y a deux solutions pour adresser les périphériques : soit les périphériques disposent d'un espace d'adressage séparé de celui de la mémoire, soit il y un espace d'adressage unique partagé entre processeur et mémoire. Les deux cas donnent des solutions différentes. Avec un espace d'adressage séparé, l'espace d'adressage des périphériques n'est pas caché : aucun accès dans cet espace d'adressage ne passe par le cache. La mémoire cache n'est utilisée que pour l'espace d'adressage des mémoires, rien d'autre. C'est de loin le cas le plus simple : il suffit de concevoir le processeur pour. Il dispose d'instructions séparées pour les accès aux registres d’interfaçage et à la RAM/ROM, les premières ne passent pas par le cache, les autres si. Avec des entrées-sorties mappées en mémoire, la même solution est utilisée, mais dans une version un peu différente. Là encore, les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache, si on veut qu'ils marchent comme ils le doivent. Cela demande d'adapter le cache et le matériel pour que accès aux périphériques mappés en mémoire contournent le cache. Des adresses, voire des zones entières de la mémoire, sont marquées comme étant non-cachables. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires caches. Là encore, le processeur doit être prévu pour : on doit pouvoir le configurer de manière à marquer certaines zones de la RAM comme non-cacheable. Reste qu'il faut marquer des régions de la RAM comme non-cacheable. Pour cela, on améliore les registres de configuration du cache, vus plus haut, afin qu'ils permettent de configurer certaines portions de la RAM pour préciser qu'elles ne doivent pas être mises en cache, qu'il faut activer le contournement de cache pour celles-ci. ===Contourner le cache pour des raisons de performance=== Il arrive que des données avec une faible localité soient chargées dans le cache inutilement. Or, il vaut mieux que ces données transitent directement entre le processeur et la mémoire, sans passer par l'intermédiaire du cache. Pour cela, le processeur peut fournir des instructions d'accès mémoire qui ne passent pas par le cache, à côté d'instructions normales. De telle instructions sont appelées des '''instructions mémoire non-temporelles'''. Non-temporelle, dans le sens : pas de localité temporelle (c.a.d que les données ne seront pas réutilisées plus tard). Mais il existe aussi des techniques matérielles, où le cache détecte à l'exécution les lectures qui gagnent à contourner le cache. La dernière méthode demande d'identifier les instructions à l'origine des défauts de cache, le processeur accédant directement à la RAM quand une telle instruction est détectée. Si une instruction d'accès mémoire fait trop de défauts de cache, c'est signe qu'elle gagne à contourner le cache. L'idée est de mémoriser, pour chaque instruction d'accès mémoire, un historique de ses défauts de cache. Il existe plusieurs méthodes pour cela, mais toutes demandent d'ajouter de quoi mémoriser l'historique des défauts de cache des instructions. L'historique est mémorisé dans une mémoire appelée la '''table d’historique des défauts de lecture''' (''load miss history table''), qui est souvent un cache. L'historique en question est, dans sa version la plus simple, un compteur de quelques bits incrémenté à chaque succès de cache et décrémenté à chaque défaut de cache, qui indique si l'instruction a en moyenne fait plus de défauts ou de succès de cache. La table associe le ''program counter'' d'une instruction mémoire à cet historique. À la première exécution d'une instruction d'accès mémoire, une entrée de cette table est réservée pour l'instruction. Lors des accès ultérieurs, le processeur récupérer les informations associées et décide s'il faut contourner le cache ou non. ==La hiérarchie mémoire des caches== [[File:Cache Hierarchy.png|vignette|Hiérarchie de caches]] On pourrait croire qu'un seul cache est largement suffisant pour compenser la lenteur de la mémoire. Hélas, les processeurs sont devenus tellement rapides que les caches sont eux-mêmes très lents ! Pour rappel, plus une mémoire peut contenir de données, plus elle est lente. Et les caches ne sont pas épargnés. Si on devait utiliser un seul cache, celui-ci serait très gros et donc trop lent. La situation qu'on cherche à éviter avec la mémoire RAM revient de plus belle. Même problème, même solution : si on a décidé de diviser la mémoire principale en plusieurs mémoires de taille et de vitesse différentes, on peut bien faire la même chose avec la mémoire cache. Depuis environ une vingtaine d'années, un processeur contient plusieurs caches de capacités très différentes : les caches L1, L2 et parfois un cache L3. Certains de ces caches sont petits, mais très rapides : c'est ceux auxquels on va accéder en priorité. Viennent ensuite d'autres caches, de taille variable, mais plus lents. Les processeurs ont donc une hiérarchie de caches qui se fait de plus en plus complexe avec le temps. Cette hiérarchie est composée de plusieurs niveaux de cache, qui vont des niveaux inférieurs proches de la mémoire RAM à des niveaux supérieurs proches du processeur. Plus on monte vers les niveaux supérieurs, plus les caches sont petits et rapides. Un accès mémoire dans une hiérarchie de cache fonctionne comme suit : on commence par vérifier si la donnée recherchée est dans le cache le plus rapide, à savoir le cache L1. Si c'est le cas,n on la charge depuis ce cache directement. Si elle n’y est pas, on vérifie si elle est dans le cache de niveau supérieur, le cache L2. Et rebelote ! Si elle n'y est pas, on vérifie le cache du niveau supérieur. Et on répète cette opération, jusqu’à avoir vérifié tous les caches. Si la donnée n'est dans aucun cache, on doit alors aller chercher la donnée en mémoire. [[File:Hiérarchie de caches.png|centre|vignette|upright=2|Hiérarchie de caches]] Il y a des différences assez notables entre chaque niveau de cache. Par exemple, les différents niveaux de cache n'ont pas forcément les mêmes politiques de remplacement des lignes de cache. Le cache L1 a généralement une politique de remplacement simple, très rapide, mais peu efficace. De même, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Le cache le plus proche de la mémoire est appelé le '''cache de dernier niveau''', ''Last Level Cache'' en anglais. Il a parfois des caractéristiques totalement différentes des autres caches. Par exemple, sur les processeurs multicoeurs, le cache L3 n'est techniquement pas dans le cœur, mais fait partie d'un ensemble de circuits reliés, comme le contrôleur mémoire ou l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc. ===Les caches exclusifs et inclusifs=== Notons que du point de vue de cette vérification, il faut distinguer les caches inclusifs et exclusifs. Avec les caches inclusifs, si une donnée est présente dans un cache, alors elle est présente dans les caches des niveaux inférieurs, ce qui implique l'existence de données en doublon dans plusieurs niveaux de cache. À l'opposé, les caches exclusifs font que toute donnée est présente dans un seul cache, pas les autres. Il existe aussi des caches qui ne sont ni inclusifs, ni exclusifs. Sur ces caches, chaque niveau de cache gère lui-même ses données, sans se préoccuper du contenu des autres caches. Pas besoin de mettre à jour les niveaux de cache antérieurs en cas de mise à jour de son contenu, ou en cas d'éviction d'une ligne de cache. La conception de tels caches est bien plus simple. Dans les '''caches exclusifs''', le contenu d'un cache n'est pas recopié dans le cache de niveau inférieur. Il n'y a pas de donnée en double et on utilise 100 % de la capacité du cache, ce qui améliore le taux de succès. Par contre, le temps d'accès est un peu plus long. La raison est que si une donnée n'est pas dans le cache L1, on doit vérifier l'intégralité du cache L2, puis du cache L3. De plus, assurer qu'une donnée n'est présente que dans un seul cache nécessite aux différents niveaux de caches de communiquer entre eux pour garantir que l'on a pas de copies en trop d'une ligne de cache, ce qui peut prendre du temps. [[File:Caches exclusifs.png|centre|vignette|upright=2|Caches exclusifs]] Dans le cas des '''caches inclusifs''', le contenu d'un cache est recopié dans les caches de niveau inférieur. Par exemple, le cache L1 est recopié dans le cache L2 et éventuellement dans le cache L3. Ce genre de cache a un avantage : le temps d'accès à une donnée est plus faible. La raison est qu'il ne faut pas vérifier tout un cache, mais seulement la partie qui ne contient pas de donnée en doublon. Par exemple, si la donnée voulue n'est pas dans le cache L1, on n'est pas obligé de vérifier la partie du cache L2 qui contient la copie du L1. Ainsi, pas besoin de vérifier certaines portions du cache, ce qui est plus rapide et permet de simplifier les circuits de vérification. En contrepartie, l'inclusion fait que qu'une partie du cache contient des copies inutiles, comme si le cache était plus petit. De plus, maintenir l'inclusion est compliqué et demande des circuits en plus et/ou des échanges de données entre caches. [[File:Caches inclusifs.png|centre|vignette|upright=2|Caches inclusifs]] Maintenir l'inclusion demande de respecter des contraintes assez fortes, ce qui ne se fait pas facilement. Premièrement, toute donnée chargée dans un cache doit aussi l'être dans les caches de niveau inférieur. Ensuite, quand une donnée est présente dans un cache, elle doit être maintenue dans les niveaux de cache inférieurs. De plus, toute donnée effacée d'un cache doit être effacée des niveaux de cache supérieurs : si une donnée quitte le cache L2, elle doit être effacée du L1. Ces trois contraintes posent des problèmes si chaque cache décide du remplacement des lignes de cache en utilisant un algorithme comme LRU, LFU, MRU, ou autre, qui utilise l'historique des accès. En effet, dans ce cas, le cache décide de remplacer les lignes de cache selon l'historique des accès, historique qui varie suivant chaque niveau de cache. Par exemple, une donnée rarement utilisée dans le L2 peut parfaitement être très fréquemment utilisée dans le L1 : la donnée sera alors remplacée dans le L2, mais sera maintenue dans le L1. On observe aussi des problèmes quand il existe plusieurs caches à un seul niveau : chaque cache peut remplacer les lignes de cache d'une manière indépendante des autres caches du même niveau, donnant lieu au même type de problème. Pour maintenir l'inclusion, les caches doivent se transmettre des informations qui permettent de maintenir l'inclusion. Par exemple, les caches de niveaux inférieurs doivent prévenir les niveaux de cache supérieurs quand ils remplacent une ligne de cache. De plus, toute mise à jour dans un cache doit être répercutée dans les niveaux de cache inférieurs et/ou supérieurs. On doit donc transférer des informations de mise à jour entre les différents niveaux de cache. Généralement, le contenu des caches d'instruction n'est pas inclus dans les caches de niveau inférieurs, afin d'éviter que les instructions et les données se marchent sur les pieds. Enfin, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Dans ce cas, l'inclusion est plus difficile à maintenir, pour des raisons assez techniques. ===Les caches eDRAM, sur la carte mère et autres=== D'ordinaire, les mémoires caches sont intégrées au processeur, à savoir que cache et CPU sont dans le même circuit imprimé. Les caches sont donc fabriqués avec de la SRAM, seule forme de mémoire qu'on peut implémenter dans un circuit intégré. Intégrer tous les caches dans le processeur est une solution et efficace. Mais certains processeurs ont procédé autrement. [[File:Cache-on-a-stick module.jpg|vignette|Cache-on-a-stick module]] Des processeurs assez anciens incorporaient un cache L1 dans le processeur, mais plaçaient un cache L2 sur la carte mère. Le cache était clippé sur un connecteur sur la carte mère, un peu comme le sont les barrettes de mémoire. On parlait alors de '''''Cache on a stick''''' (COAST). On aurait pu s'attendre à ce que de tels caches soient en DRAM, vu qu'ils sont placés sur des barrettes de RAM, mais la ressemblance avec la mémoire RAM principale s'arrête là. Le cache était fabriqué en mémoire SRAM, même s'il est en théorie possible de faire de tels caches avec de la DRAM. Les premiers processeurs avec un cache faisaient ainsi, au début des années 90. Il a été introduiot sur les processeurs Motorola, et a été utilisé sur les IBM PC et les Macintosh de l'époque. Les ordinateurs Macintosh utilisaient de tels caches, pour la pluaprt des modèles. Pour ce qui est des PC, les premiers processeurs x86 faisaient pareil, notamment les processeurs Intel. Le 486, le Pentium et le Pentium 2 utilisaient des ''Cache on a stick''. L'avantage est que cela permettait de mettre plus de cache, à une époque où les circuits étaient limités en transistors. De plus, cela permettait au consommateur de choisir quelle quantité de cache il voulait, selon ses finances. Il était possible de laisser le processeur fonctionner soit sans mémoire cache, soit avec un cache de 256 Kibioctets, de 512 Kibioctets, etc. Il était possible d'upgrader le cache si besoin. Pour les CPU Intel, le cache était connecté sur le bus système, au même titre que la mémoire RAM et les entrées-sorties. Il faut dire que les processeurs de l'époque utilisaient un bus système et n'avaient pas de bus mémoire dédié. Mais en théorie, rien n’empêche de connecter le cache sur un bus mémoire dédié. Toujours est-il que les lectures et écritures étaient propagées à la fois dans le cache et la RAM. Les écritures se faisaient dans les deux, systématiquement dans la RAM, mais aussi dans le cache en cas de succès de cache. Les lectures étaient servies soit par le cache en cas de succès de cache, soit par la RAM en cas de défaut de cache. Si le cache répondait en premier, la transaction sur le bus se terminait précocement et l'accès en RAM était abandonné. [[File:Intel486 Иерархия памяти.png|centre|vignette|upright=2.5|Intel486 : le cache était connecté sur le bus système.]] À l'inverse, certains processeurs possédaient un cache fabriqué en mémoire DRAM, et plus précisément avec de la mémoire eDRAM. Le cache n'était pas intégré dans le même circuit imprimé que le processeur, mais profitait d'une architecture en ''chiplet''. Pour rappel, cela veut dire que le processeur est en réalité composé de plusieurs circuits intégré séparés, mais interconnectés et soudés sur un même PCB carré. Avec un cache en eDRAM, le cache avait son propre circuit intégré, séparé du circuit intégré du processeur ou du circuit intégré pour le contrôleur mémoire/IO. Un exemple est celui du cache des processeurs Intel de microarchitecture Broadwell, vus dans ce chapitre dans la section sur les caches splittés. Les tags étaient intégrés dans le circuit intégré du processeur, mais les données étaient mémorisées dans une puce d'eDRAM séparée. La puce eDRAM correspondait en réalité à une DRAM adressable qui servait de DRAM pour les données et mémorisaient les voies du cache. ==Les caches adressés par somme et hashés== Les caches adressés par somme sont optimisés pour incorporer certains calculs d'adresse directement dans le cache lui-même. Pour rappel, certains modes d'adressage impliquent un calcul d'adresse, qui ajoute une constante à une adresse de base. Généralement, l'adresse de base est l'adresse d'un tableau ou d'une structure, et la constante ajoutée indique la position de la donnée dans le tableau/la structure. Les caches hashés et les caches adressés par somme permettent de faire l'addition directement dans la mémoire cache. Voyons d'abord les caches hashés, avant de passer aux caches adressés par somme. Sur les '''caches hashés''', l'addition est remplacée par une autre opération, par exemple des opérations bit à bit du style XOR, AND ou OR, etc. Seulement, utiliser des opérations bit à bit pose un problème : il arrive que deux couples Adresse/décalage donnent le même résultat. Par exemple, le couple Adresse/décalage 11101111/0001 donnera la même adresse que le couple 11110000/0000. Dit autrement, deux adresses censées être différentes (après application du décalage) sont en réalité attribuées à la même ligne de cache. Il est toutefois possible de gérer ces situations, mais cela demande des astuces de haute volée pour faire fonctionner la mémoire cache correctement. Sur les '''caches adressés par somme''', le décodeur est modifié pour se passer de l'addition. Pour comprendre comment, il faut rappeler qu'un décodeur normal est composé de comparateurs, qui vérifient si l'entrée est égale à une constante bien précise. Sur un cache ordinaire, l'addition est faite séparément du décodage des adresses par le cache, dans l'unité de calcul ou dans l'unité de génération d'adresse. [[File:Non sum adressed cache.png|centre|vignette|upright=2|Cache normal.]] Mais les caches adressés par somme modifient le décodeur, qui est alors composé de comparateurs qui testent si la somme adresse + décalage est égale à une constante. [[File:Cache adressé par somme.png|centre|vignette|upright=2|Cache adressé par somme.]] Chaque circuit du décodeur fait le test suivant, avec K une constante qui dépend du circuit : : <math>A + B = K</math> Ce qui est équivalent à faire le test suivant : : <math>A + B - K = 0</math> En complément à deux, on a <math>- K = \overline{K} + 1</math>. En injectant dans l'équation précédente, on a : : <math>A + B + \overline{K} + 1 = 0</math> En réorganisant les termes, on a : : <math>A + B + \overline{K} = - 1</math> Il suffit d'utiliser un additionneur ''carry-save'' pour faire l'addition des trois termes. Rappelons qu'un tel additionneur fournit deux résultats en sortie : une somme calculée sans propager les retenues et les retenues en question. Notons que les retenues sont à décaler d'un cran, vu qu'elles sont censées s'appliquer à la colonne suivante. En notant la somme S et les retenues R, on a: : <math>S + (R << 1) = - 1 </math>, le décalage d'un cran à gauche étant noté <math><< 1</math>. Ensuite, -1 est codé avec un nombre dont tous les bits sont à 1 en complément à un/deux. : <math>S + (R << 1) = 111 \cdots 111111</math> [[File:Sum + retenue add.png|centre|vignette|upright=2|Sum + retenue add]] Un simple raisonnement nous permet de savoir si le résultat est bien -1, sans faire l'addition <math>S + (R << 1)</math>. En effet, on ne peut obtenir -1 que si la somme est l'inverse des retenues : un 0 dans le premier nombre correspond à un 1 dans l'autre, et réciproquement. En clair, on doit avoir <math>\overline{S} = R << 1</math>. Pour vérifier cela, il suffit de faire un simple XOR entre la somme et les retenues décalées d'un cran. On a alors : : <math>S \oplus (R << 1) = 111 \cdots 111111</math> La comparaison avec -1 se fait avec une porte ET à plusieurs entrées. En effet, la porte donnera un 1 seulement si tous les bits d'entrée sont à 1, ce qui est ce qu'on veut tester. Au final, l'additionneur pour l'addition adresse + décalage est remplacé par un additionneur carry-save suivi d'une couche de portes XOR et d'un comparateur avec une constante, ce qui économise de circuits et améliore les performances. [[File:Final circuit of sum addressed cache.png|centre|vignette|upright=2|Cache adressé par somme.]] En prenant en compte que la constante K est justement une constante, certaines entrées de l'additionneur carry-save sont toujours à 0 ou à 1, ce qui permet quelques simplifications à grand coup d’algèbre de Boole. Chaque additionneur complet qui compose l’additionneur carry-save est remplacée par des demi-additionneurs (ou par un circuit similaire). Autant dire que l'on gagne tout de même un petit peu en rapidité, en supprimant une couche de portes logiques. Le circuit de décodage économise aussi des portes logiques, ce qui est appréciable. ==Les caches à accès uniforme et non-uniforme== Intuitivement, le temps d'accès au cache est le même pour toutes les lignes de cache. Il s'agit de cache appelés '''caches à accès uniforme''', sous-entendu à temps d'accès uniforme. Mais sur les caches de grande capacité, il arrive souvent que le temps de propagation des signaux varie fortement suivant la ligne de cache à lire. D'ordinaire, on se cale sur la ligne de cache la plus lente pour caler la fréquence d'horloge du cache, même si on pourrait faire mieux. Cependant, les '''caches à accès non uniforme''' ont une latence différente pour chaque ligne d'un même cache. Certaines lignes de cache sont plus rapides que d'autres. Niveau terminologie, nous allons parler de caches UCA et NUCA : ''Uniform Access Cache'' pour les caches à accès uniforme, ''Non-Uniform Access Cache'' pour les caches à accès non-uniforme. [[File:Caches UCA et NUCA.png|vignette|Caches UCA et NUCA.]] Les caches NUCA et UCA sont souvent composés de plusieurs banques séparées, typiquement une par voie. Sur les caches UCA, les banques sont interconnectées avec le processeur de manière à ce que toutes les interconnexions ont la même longueur pour toutes les banques. Typiquement, les banques sont organisées en carré, avec les interconnexions qui partent du centre, avec une disposition en H, illustrée ci-contre Mais avec les caches NUCA, ce n'est pas le cas. Les interconnexions sont simplifiées et ont des longueurs différentes. Les caches NUCA n'ont pas tous le même genre d'interconnexions, qui dépendent du cache NUCA. En général, les interconnexion forme un réseau avec des sortes de routeurs qui redirigent les données/commandes vers la bonne destination : cache ou processeur. Les banques plus proches du processeur sont accessibles plus rapidement que celles éloignées, même si la différence n'est pas énorme. Les caches NUCA sont généralement associatifs par voie. Les plus simples utilisent une banque par voie pour le cache, ce qui fait que certaines voies répondent plus vite que les autres. La détection des succès de cache est alors plus rapide si la donnée lue/écrite est dans une voie/banque rapide. En théorie, les défauts de cache demandent de vérifier toutes les banques, et se calent donc sur la pire latence. Mais divers caches se débrouillent pour que ce ne soit pas le cas, soit en vérifiant les banquyes unes par une, soit par un mécanisme de recherche plus complexe. Les caches NUCA sont surtout utilisés pour les caches L3 et L4, éventuellement les caches L2. Les caches L1 sont systématiquement des caches UCA, car la latence de l'accès au cache L1 est utilisée par le processeur pour décider quand lancer les instructions. Pour simplifier, le processeur peut démarrer en avance une instruction avant qu'une opérande soit lue dans le cache L1, de manière à ce que la donnée arrive en entrée de l'ALU pile en même temps que l'instruction. Une histoire d'exécution dans le désordre et d'émission anticipée des instructions qu'on détaillera dans une bonne dizaine de chapitres. Toujours est-il que tout est plus simple pour le processeur si le cache L1 a un temps d'accès fixe. Par contre, les caches L3 et L4 sont traités en attendant que les données arrivent, le processeur reprend l'exécution des instructions quand les caches L3 et L4 ont terminé de répondre, pas avant. Avec l'association une banque = une voie, la correspondance ligne de cache → bloc de mémoire qui est statique : on ne peut pas déplacer le contenu d'une ligne de cache dans une autre portion de mémoire plus rapide suivant les besoins. Mais la recherche académique a étudié le cas où la correspondance entre une ligne de cache et une banque varie à l’exécution. Pour nommer cette distinction, on parle de caches S-NUCA (''Static NUCA'') et D-NUCA (''Dynamic NUCA''). Intuitivement, on s'attend à ce que les caches D-NUCA soient plus performants que les caches S-NUCA. Les lignes de cache les plus utilisées peuvent migrer dans une banque rapide, alors que les lignes de cache moins utilisées vont dans une banque éloignée. Les lignes de cache se répartissent dans le cache dynamiquement dans les banques où elles sont le plus adaptées. Mais paradoxalement, le gain des caches D-NUCA est presque nul, voire insignifiant. La raison est que les caches D-NUCA doivent incorporer un système pour déterminer dans quelle banque se situe la donnée pour détecter les succès/défauts de cache, ainsi qu'un système pour migrer les données entre banques. Et ce système augmente le temps d'accès au cache, réduisant à néant l'intérêt d'un cache D-NUCA. Si on économise quelques microsecondes de temps d'accès en passant d'un cache UCA à un cache S-NUCA, ce n'est pas pour les perdre en passant à un D-NUCA. La majorité des caches D-NUCA sont donc en cours de recherche, mais ne sont pas utilisés en pratique. ==La tolérance aux erreurs des caches== Une mémoire cache reste avant tout une mémoire RAM, bien que ce soit de la SRAM. Elle n'est pas parfaite et est donc sujette à des erreurs, qui peuvent inverser un bit ou l'effacer. De telles erreurs sont liées à des rayons cosmiques très énergétiques, à des particules alpha produites par le packaging ou le métal deu circuit intégré, peu importe : l'essentiel est qu'ils inversent parfois un bit. Les mémoires modernes savent se protéger contre de telles erreurs, en utilisant trois moyens. ===Les mémoires caches ECC et à bit de parité=== Le premier moyen est l'usage de codes correcteurs d'erreurs, qui ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Les bits ajoutés dépendent de la donnée mémorisée dans le byte, et servent à détecter une erreur, éventuellement à la corriger. Le cas le plus simple ajoute un simple bit de parité pour chaque byte et se contente de détecter les erreurs dans les corriger. Les autres codes ECC permettent eux de corriger des erreurs, mais ils demandent d'ajouter au moins deux bits par byte, ce qui a un cout en circuit plus élevé. Un simple bit de parité permet de détecter qu'un bit a été inversé, mais ne permet pas de corriger l'erreur. En soi, ce n'est pas un problème. Si une erreur est détectée, on considère que la ligne de cache est invalide. Le cache gère la situation comme un défaut de cache et va chercher la donnée valide en mémoire RAM. Le cout en circuits est donc faible, mais les défauts de cache sont plus nombreux. Les codes ECC sont eux capables de corriger les erreurs, si elles ne modifient pas trop de bits d'un coup. Par contre, ils utilisent deux à trois bits par octet, ce qui a un cout en circuits loin d'être négligeable. Il y a donc un compromis entre défauts de cache et cout en circuits. La gestion de l'ECC est différente suivant le niveau de cache. Généralement, le cache L1 n'utilise pas l'ECC mais se contente d'un simple bit de parité pour éviter la corruption de ses données. Le cache étant petit, les corruptions de données sont assez rares, et les défauts de cache induits faibles. Il est plus important d'utiliser un code de détection d'erreur simple, rapide, qui ne ralentit pas le cache et n'augmente pas sa latence. Si une ligne de cache est corrompue, il a juste à aller lire la ligne depuis le cache L2, ou un niveau de cache inférieur. Du moins, c'est possible sur le cache en question est un cache inclusif et/ou ''write-through''. Par contre, le niveau de cache L2 et ceux en-dessous utilisent presque systématiquement une mémoire SRAM ECC. La raison principale étant que ce sont des caches assez gros, pour lesquels la probabilité d'une erreur est assez élevée. Plus une mémoire a de bits et prend de la place, plus il y a une chance élevée qu'un bit s'inverse. Et vu que les caches L2/L3/L4 sont par nature plus lents et plus gros, ils peuvent se permettre le cout en performance lié à l'ECC, idem pour le cout en circuit. Sans compter qu'en cas d'erreur, ils doivent aller lire la ligne de cache originelle en mémoire RAM, ce qui est très lent ! Mieux vaut corriger l'erreur sur place en utilisant l'ECC. ===L'usage du ''memory scrubbing'' sur les caches=== La plupart des erreurs ne changent qu'un seul bit dans un byte, mais le problème est que ces erreurs s'accumulent. Entre deux accès à une ligne de cache, il se peut que plusieurs erreurs se soient accumulées, ce qui dépasse les capacités de correction de l'ECC. Dans ce cas, il existe une solution appelée le ''memory scrubbing'', qui permet de résoudre le problème au prix d'un certain cout en performance. Pour rappel, l'idée est de vérifier les lignes de caches régulièrement, pour éviter que les erreurs s'accumulent. Par exemple, on peut vérifier chaque ligne de cache toutes les N millisecondes, et corriger une éventuelle erreur lors de cette vérification. En faisant des vérifications régulières, on garantir que les erreurs n'ont pas le temps de s'accumuler, sauf en cas de malchance avec des erreurs très proches dans le temps. Il ne s'agit pas d'un rafraichissement mémoire, car les SRAM ne s'effacent pas), mais ça a un effet similaire. Et évidemment, le ''memory scrubbing'' a un cout en performance. On peut faire une comparaison avec le rafraichissement mémoire : les rafraichissement réguliers réduisent les performances, car cela fait des accès en plus. Des accès qui sont de plus timés à des instants bien précis qui ne sont pas forcément les plus adéquats. Il est possible qu'un rafraichissement ait lieu en même temps qu'un accès mémoire et le rafraichissement a la priorité, ce qui réduit les performances. La même chose arrive avec les vérifications du ''memory scrubbing''. Malgré tout, la technique a été utilisée sur les caches de certains processeurs commerciaux, dont des processeurs AMD Athlon et Athlon 64. Elle est surtout utilisable sur les caches L2/L3, pour lesquels le cout du pseudo-rafraichissement est acceptable. ==Un exemple de cache : le cache d'instruction== La grande majorité des processeurs utilise deux caches L1 séparés : un '''cache d'instructions''' dédié aux instructions, et un autre pour les données. Une telle organisation permet de charger une instruction tout en lisant une donnée en même temps. Notons que seul le cache L1 est ainsi séparé entre cache de données et d'instructions. Le cache d’instruction se situe en théorie entre l'unité de chargement et l'unité de décodage. En effet, ce cache prend en entrée une adresse et fournit une instruction. L'adresse est fournie par le ''program counter'', l'instruction est envoyée dans l'unité de décodage. Le cache se situe donc entre les deux. Le cache de données L1 est connecté au chemin de données, et notamment aux unités de communication avec la mémoire, pas au séquenceur. [[File:Caches L1 et positions dans le processeur.png|centre|vignette|upright=2.5|Caches L1 et positions dans le processeur]] Les deux caches sont reliés au processeur par des bus séparés, l'ensemble ressemble à une architecture Harvard, mais où les caches remplacent les mémoires RAM/ROM. Le cache d'instruction prend la place de la mémoire ROM et le cache de données prend la place de la mémoire RAM. Évidemment, il y a des niveaux de caches en dessous des caches de données/instruction, et ceux-ci contiennent à la fois données et instructions, les deux ne sont pas séparées dans des mémoires/caches séparés. Raison pour laquelle l'ensemble est appelé une '''architecture Harvard modifiée'''. Architecture Harvard, car l'accès aux données et instructions se font par des voies séparées pour le processeur, modifiée car la séparation n'est effective que pour le cache L1 et pas les autres niveaux de cache, et encore moins la RAM. Sur les processeurs modernes, il arrive très souvent que le processeur doive charger une instruction et lire/écrire une donnée en même temps. Et à vrai dire, c'est la règle plus que l'exception. L'usage d'une architecture Harvard modifiée permet cela très facilement : on peut accéder au cache d'instruction via un bus, et au cache de donnée avec l'autre ===Pourquoi scinder le cache L1 en cache d'instruction et de données=== L'usage d'un cache d’instruction séparé du cache de données est à contraster avec l'usage d'un cache L1 multiport unique, capable de mémoriser à la fois instructions et données. Les deux solutions sont possibles ont été utilisées. Les premiers processeurs avaient un cache L1 unique et multiport, mais ce n'est plus le cas sur les processeurs modernes, car les contraintes ne sont pas les mêmes. Le compromis à faire est celui entre deux petits caches rapides et un gros cache plus lent. Pour rappel, plus un cache est petit, plus il est rapide et chauffe moins. Donc au lieu d'utiliser, par exemple, un gros cache lent de 64 Kibioctets, on utilise deux caches de 32 kibioctets, plus rapides. La capacité totale est la même, mais le temps d'accès plus faible. En termes de temps d'accès, la meilleure solution est celle des deux caches simple port. Mais pour ce qui est de l'économie de circuits, c'est moins évident. Entre deux mémoires simple port et une mémoire multiport, la différence en termes de transistors est ambigüe et dépend de la capacité des caches. La différence est surtout notable pour les gros caches, moins pour les petits caches. Il faut aussi tenir compte de la capacité effective. Avec deux caches séparés, la répartition de la capacité du cache L1 est fixée une bonne fois pour toutes. Par exemple, avec un cache d'instruction de 32 KB et un cache de données de 32 KB, impossible d'allouer 40 KB aux données et 20 aux instructions. Alors qu'avec un cache L1 unique de 64 KB, on pourrait le faire sans soucis. La répartition se fait naturellement, en fonction de la politique de remplacement du cache et est proche de l'optimal. C'est là un désavantage des caches d'instructions/données séparés : une capacité effective moindre. Tout cela explique pourquoi le cache L1 est le seul à être ainsi scindé en deux, avec une séparation entre instructions et données : les contraintes au niveau du cache L1 et L2 ne sont pas les mêmes. Pour les caches L1, le temps d'accès est plus important que la capacité, ce qui favorise les caches séparés. Par contre, pour les caches L2/L3/L4, le temps d'accès n'est pas déterminant, alors que la capacité effective et l'économie en circuits sont significatives. ===La connexion des caches L1 avec le cache L2=== Pour les connexions avec le cache L2, tout dépend du processeur. Certains utilisent un cache L2 multiport, qui permet aux deux caches L1 de lire ou écrire dans le cache L2 simultanément. [[File:Cache d'instructions.png|centre|vignette|upright=1.5|Cache d'instructions.]] Si le cache L2 ne gère pas les accès simultanés, il n'y a qu'un seul bus relié aux caches L1 et au cache L2. On doit effectuer un arbitrage pour décider quel cache a la priorité, chose qui est réalisé par un circuit d'arbitrage spécialisé. [[File:Circuit d'arbitrage du cache.png|centre|vignette|upright=1.5|Circuit d'arbitrage du cache.]] Généralement, les caches d'instructions peuvent se permettre d'être plus petits que les caches de données, car les programmes sont souvent plus petits que les données manipulées. Songez que des programmes de quelques mébioctets peuvent parfois remplir la RAM avec plusieurs gibioctets de données. Lancez votre navigateur internet et ouvrez une page web un peu chargée, pour vous en convaincre ! ===Les spécificités du cache d'instruction : lecture seule, bloquant, etc=== Les instructions sont rarement modifiées ou accédées en écritures, contrairement aux données. Et cela permet d'utiliser un cache simplifié pour les instructions. Autant un cache généraliste doit permettre les lectures et écritures depuis le processeur (avec les échanges avec la RAM), autant un cache d'instruction peut se contenter des lectures provenant du CPU et des échanges avec la RAM. Le cache d'instructions est donc très souvent en « lecture seule » : le processeur ne peut pas écrire dedans, mais juste le lire ou charger des instructions dedans. Un cache d'instruction est donc plus simple qu'un cache pour les données : on peut retirer les circuits en charge de l'écriture (mais on doit laisser un port d'écriture pour charger les instructions dedans). Le gain en circuits permet d'utiliser un cache d'instruction plus gros ou au contraire de laisser de la place pour le cache de données. Le gain en termes de capacité compense alors un peu les inconvénients des caches séparés. Par contre, cela complique la gestion du code automodifiant, c'est-à-dire des programmes dont certaines instructions vont aller en modifier d'autres, ce qui sert pour faire de l'optimisation ou est utilisé pour compresser ou cacher un programme (les virus informatiques utilisent beaucoup de genre de procédés). Quand le processeur exécute ce genre de code, il ne peut pas écrire dans ce cache L1 d'instructions, mais doit écrire dans le cache L2 ou en RAM, avant de recharger les instructions modifiées dans le cache L1. Cela qui prend du temps et peut parfois donner lieu à des erreurs si le cache L1 n'est pas mis à jour. Les algorithmes de remplacement des lignes de cache optimaux pour les données ne le sont pas pour les instructions, de même que la taille optimale du cache, la taille des lignes de cache optimale, ou même les algorithmes de préchargement. Par exemple, pour le remplacement des lignes de cache, un simple algorithme LRU est presque optimal pour les instructions, autant il peut donner de mauvaises performances quand on manipule beaucoup de tableaux. Cela justifie d'utiliser des caches spécialisés pour chacune. On peut adapter le cache d'instruction à son contenu, ce qui le rend plus rapide ou plus petit à performance égale. Les caches d'instructions sont généralement des caches bloquants. Il ne servirait à rien de rendre un cache d'instruction non-bloquant, le cout en circuits ne se traduirait pas par une augmentation significative des performances. À l'opposé, les caches de données sont non-bloquants sur les architectures modernes, pour des raisons de performance. Ce qui rend la séparation assez intéressante, les deux caches ayant des besoins différents et des implémentations différentes, cela permet d'optimiser le cout en transistors des caches. ===L'impact du cache d'instruction sur les performances=== Sur les architectures conventionnelles, le cache d'instruction a plus d'impact sur les performances que le cache de données. La raison principale est que les instructions ont une meilleure localité spatiale et temporelle que pour les données. Pour la localité spatiale, les instructions consécutives se suivent en mémoire, alors que rien ne garantit que des données utilisées ensemble soient regroupées en mémoire. Pour localité temporelle, elle est très variable pour les données, mais très courante pour les instructions du fait de l'usage fréquent des boucles et des fonctions. : La présence de branchements atténue la localité temporelle des instruction, sauf que la majorité des branchements sautent à un endroit très proche, seuls les appels de fonction brisent la localité spatiale. La conséquence est qu'il arrive que certains CPU aient un cache L1 d'instruction plus gros que celui pour les données. On parle alors de '''cache L1 asymétriques'''. Un exemple est celui des processeurs AMD de microarchitecture Zen, dont le cache d'instruction était deux fois plus gros que le cache de données. Leur cache d'instruction faisait 64 kibioctets, contre seulement 32 pour le cache de données. D'ailleurs, il existe des processeurs assez extrêmes qui se contentent d'un cache d'instruction unique, sans cache de données. C'est le cas sur les processeurs vectoriels ou les GPU que nous verrons dans les chapitres de fin de ce wikilivres. De tels processeurs sont spécialisés dans la manipulation de tableaux de données, traitement qui a une faible localité temporelle. En conséquence, utiliser un cache de données n'est pas vraiment utile, voire peu être contreproductif, alors qu'un cache d’instruction fonctionne parfaitement. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Compléments sur les mémoires de masse | prevText=Compléments sur les mémoires de masse | next=Le préchargement | nextText=Le préchargement }} </noinclude> b3iey49vje7q8veiq8q05uiwh3ukv6c 765129 765128 2026-04-26T16:42:11Z Mewtow 31375 /* Le contrôleur de cache 82385 pour les CPU Intel 386 */ 765129 wikitext text/x-wiki Le cache est une mémoire intercalée entre la mémoire et un processeur, plus rarement à l'intérieur d'un périphérique. Il est souvent fabriquée avec de la mémoire SRAM, parfois avec de l'eDRAM. Sans lui, on se croirait à l'âge de pierre tellement nos PC seraient lents ! En effet, la mémoire est très lente comparée au processeur. Le temps mis pour accéder à la mémoire est du temps durant lequel le processeur n'exécute pas d'instruction (sauf cas particuliers impliquant un pipeline). Pour diminuer ce temps d'attente, il a été décidé d'intercaler une mémoire petite mais rapide, entre le processeur et la mémoire. Ainsi, le processeur accède à un cache très rapide plutôt qu'à une RAM beaucoup plus lente. ==L'accès au cache== Le cache contient une copie de certaines données présentes en RAM. La copie présente dans le cache est accessible bien plus rapidement que celle en RAM, vu que le cache est plus rapide. Mais seule une petite partie de ces données sont copiées dans le cache, les autres données devant être lues ou écrites dans la RAM. Toujours est-il que le cache contient une copie des dernières données accédées par le processeur. Une donnée est copiée dans la mémoire cache quand elle est lue ou écrite par le processeur. Le processeur conserve une copie de la donnée dans le cache après son premier accès. Les lectures/écritures suivantes se feront alors directement dans le cache. Évidemment, au fur et à mesure des accès, certaines données anciennes sont éliminées du cache pour faire de la place aux nouveaux entrants, comme nous le verrons plus tard. [[File:Principe d'une mémoire cache.gif|centre|vignette|upright=2|Principe d'une mémoire cache.]] La mémoire cache est invisible pour le programmeur, qui ne peut pas déceler celles-ci dans l'assembleur. Les accès mémoire se font de la même manière avec ou sans le cache. La raison à cela est que le cache intercepte les accès mémoire et y répond s'il en a la capacité. Par exemple, si le cache intercepte une lecture à une adresse et que le contenu de cette adresse est dans le cache, le cache va outrepasser la mémoire RAM et la donnée sera envoyée par le cache au lieu d'être lue en RAM. par contre, si un accès se fait à une adresse pour laquelle le cache n'a pas la donnée, alors l'accès mémoire sera effectué par la RAM de la même manière que si le cache n'était pas là. [[File:Accès au cache.png|centre|vignette|upright=2|Accès au cache]] ===Les succès et défauts de caches=== Tout accès mémoire est intercepté par le cache, qui vérifie si la donnée demandée est présente ou non dans le cache. Si la donnée voulue est présente dans le cache, on a un '''succès de cache''' (''cache hit'') et on accède à la donnée depuis le cache. Sinon, c'est un '''défaut de cache''' (''cache miss'') et on est obligé d’accéder à la RAM. Les défauts de cache peuvent avoir plusieurs origines. Tout ce qu'il faut savoir est que lorsque le processeur accède à une donnée ou une instruction pour la première fois, il la place dans la mémoire cache car elle a de bonnes chances d'être réutilisée prochainement. La raison à cela est qu'un programme a tendance à réutiliser les instructions et données qui ont été accédées dans le passé : c'est le ''principe de localité temporelle''. Bien évidement, cela dépend du programme, de la façon dont celui-ci est programmé et accède à ses données et du traitement qu'il fait, mais c'est souvent vrai en général. La première cause des défauts de cache est liée à la taille du cache. À force de charger des données/instructions dans le cache, le cache fini par être trop petit pour conserver les anciennes données. Le cache doit bien finir par faire de la place en supprimant les anciennes données, qui ont peu de chances d'être réutilisées. Ces anciennes données éliminées du cache peuvent cependant être accédées plus tard. Tout prochain accès à cette donnée mènera à un cache miss. C'est ce qu'on appelle un ''Capacity Cache Miss'', ou encore '''défaut de capacité'''. Les seules solutions pour éviter cela consistent à augmenter la taille du cache ou à optimiser le programme exécuté (voir plus bas). Une autre raison pour un défaut est donc la suivante. Lorsqu'on exécute à une instruction ou qu'on accède à donnée pour la première fois, celle-ci n'a pas encore été chargée dans le cache. Le défaut de cache est inévitable : ce genre de cache miss s'appelle un ''Cold Miss'', ou encore un '''défaut à froid'''. De tels défauts sont presque impossibles à éliminer, sauf à utiliser des techniques de préchargement qui chargent à l'avance des données potentiellement utiles. Ces méthodes de préchargement se basent sur le principe de localité spatiale, à savoir le fait que les programmes ont tendance à accéder à des données proches en mémoire. Pour donner un exemple, les instructions d'un programme sont placées en mémoire dans l’ordre dans lequel on les exécute : la prochaine instruction à exécuter est souvent placée juste après l'instruction en cours (sauf avec les branchements). Quand on accède à une donnée ou une instruction, le cache peut précharger les données adjacentes pour en profiter. Nous parlerons de ces techniques de préchargement dans un chapitre dédié, vers la fin du cours. ===Le fonctionnement du cache, vu du processeur=== Vu du processeur, le cache prend en entrée toutes les informations nécessaires pour effectuer un accès mémoire : des signaux de commande, une adresse et la donnée à écrire si besoin. Tout cela est passé en entrée du cache, celui-ci répondant aux accès mémoire via divers bits de contrôles, que le processeur peut lire à souhait. Le cache fournit aussi la donnée à lire, pour les lectures, sur une sortie, connectée directement au bus mémoire/processeur. Globalement, le cache a une capacité limitée, mais il prend en entrée des adresses complètes. Par exemple, sur un processeur 64 bits, le cache prend en entrée des adresses de 64 bits (sauf si optimisations), même si le cache en question ne fait que quelques mébioctets. Les caches sont souvent des mémoires multiports, surtout sur les processeurs récents. Les caches simple port sont rares, mêmes s'ils existent et ont existé par le passé. les caches double port sont eux plus fréquents, et ont généralement un port d'écriture séparé du port de lecture. Mais les caches récents ont plusieurs ports de lecture/écriture et sont capables de gérer plusieurs accès mémoire simultanés. Les données présentes dans le cache sont (pré)chargées depuis la mémoire, ce qui fait que toute donnée dans le cache est la copie d'une donnée en mémoire RAM. Le cache doit faire la correspondance entre une donnée du cache et l'adresse mémoire correspondante. Du point de vue du fonctionnement, on peut voir le cache comme une sorte de table de correspondance, qui mémorise des données, chacune étant associée à son adresse mémoire. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Cela vaut du point de vue du processeur, le fonctionnement interne du cache étant quelque peu différent selon le cache. Il existe des caches dont le fonctionnement interne est bien celui d'une table de correspondance matérielle, d'autres qui sont beaucoup plus optimisés. [[File:Fonctionnement d'une mémoire associative à correspondance.png|centre|vignette|upright=2|Fonctionnement simplifié d'une mémoire cache : les adresses sont dans la colonne de gauche, les données sont dans la colonne de droite. On voit qu'on envoie l'adresse au cache, que celui-ci répond en renvoyant la donnée associée.]] ==La performance des mémoires caches== L'analyse de la performance des mémoires caches est plus riche pour celle des autres mémoires. Sa performance dépend de beaucoup de paramètres, mais on peut cependant citer les principaux. Les deux premiers sont tout bonnement sa latence et son débit, comme pour n'importe quelle autre mémoire. La latence est plus importante que son débit, car le processeur est généralement plus rapide que le cache et qu'il n'aime pas attendre. Mais le critère le plus important pour un cache est sa capacité à empêcher des accès mémoire, son efficacité. Plus les accès mémoire sont servis par le cache au lieu de la RAM, meilleures seront les performances. Pour résumer, la performance d'un cache est surtout caractérisée par deux métriques : le taux de défaut, qui correspond à l’efficacité du cache, et la latence du cache. ===Le taux de succès/défaut=== Le '''taux de succès''' (hit ratio) est un premier indicateur des performances du cache, mais un indicateur assez imparfait. C'est le pourcentage d'accès mémoire qui ne déclenchent pas de défaut de cache. Plus il est élevé, plus le processeur accède au cache à la place de la RAM et plus le cache est efficace. Certains chercheurs préfèrent utiliser le '''taux de défauts''', à savoir le pourcentage d'accès mémoire qui entraînent un défaut de cache. Plus il est bas, meilleures sont les performances. Le taux de défaut est relié au taux de succès par l'équation <math>T_\text{succes} = 1 - T_\text{defaut}</math>. Par définition, il est égal à : : <math>\text{Taux de défauts de cache} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d’accès mémoires}}</math> Plutôt que de comparer le nombre de défauts/succès de cache au nombre d'accès mémoire, il est aussi possible de diviser le nombre de défauts par le nombre total d'instructions. On obtient alors le '''taux de défauts/succès par instruction''', une autre métrique utile. Par définition, elle est égale à : : <math>\text{Taux de défauts par instruction} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d'instructions}} = \text{Taux de défauts de cache} \times \frac{\text{Nombre d’accès mémoires}}{\text{Nombre d'instructions}}</math> Si certains défauts de cache sont inévitables quel que soit le cache, comme les défauts à froids, mentionnés plus haut, d'autres défauts peuvent être évités en augmentant la capacité du cache. C'est le cas des défauts de capacité qui sont causés par un accès à une donnée qui a été éliminée du cache faute de place. Plus le cache est gros, moins il a de chances d'être rempli, moins il doit rapatrier de données, plus son taux de succès augmente. Mais nous reviendrons sur le lien entre taille du cache et taux de défaut plus bas. Le taux de succès ne dépend pas que du cache, mais aussi de la conception des programmes exécutés. Une bonne utilisation du cache (ainsi que de la mémoire virtuelle) repose sur le programmeur qui doit prendre en compte les principes de localités dès la conception de ses programmes. Par exemple, un programmeur peut parfaitement tenir compte du cache au niveau de son algorithme : on peut citer l'existence des algorithmes ''cache oblivious'', qui sont conçus pour être optimaux quelle que soit la taille du cache. Le programmeur peut aussi choisir ses structures de données de manière à améliorer la localité. Par exemple, un tableau est une structure de donnée respectant le principe de localité spatiale, tandis qu'une liste chaînée ou un arbre n'en sont pas (bien qu'on puisse les implémenter de façon à limiter la casse). D'autres optimisations sont parfois possibles : par exemple, le sens de parcours d'un tableau multidimensionnel peut faire une grosse différence. Cela permet des gains très intéressants pouvant se mesurer avec des nombres à deux ou trois chiffres. Je vous recommande, si vous êtes programmeur, de vous renseigner le plus possible sur les optimisations de code ou algorithmiques qui concernent le cache : il vous suffira de chercher sur Google. Il y a une citation qui résume bien cela, prononcée par un certain Terje Mathisen. Si vous ne le connaissez pas, cet homme est un vieux programmeur (du temps durant lequel on codait encore en assembleur), grand gourou de l’optimisation, qui a notamment travaillé sur le moteur de Quake 3 Arena. {{BlocCitation|Almost all programming can be viewed as an exercise in caching.|auteur=Terje Mathisen}} ===La latence moyenne d'un cache=== Le temps mis pour lire ou écrire une donnée varie en présence d'un cache. Certaines lectures/écritures vont atterrir directement dans le cache (succès) tandis que d'autres devront aller chercher leur contenu en mémoire RAM (défaut de cache). Dans tous les cas, qu'il y ait défaut ou non, le cache sera consulté et mettra un certain temps à répondre, égal au temps de latence du cache. Tous les accès mémoires auront donc une durée au moins égale au temps de latence du cache, qui sera notée <math>T_c</math>. En cas de succès, le cache aura effectué la lecture ou l'écriture, et aucune action supplémentaire n'est requise. Ce qui n'est pas le cas en cas de défaut : le processeur devra aller lire/écrire la donnée en RAM, ce qui prend un temps supplémentaire égal au temps de latence de la mémoire RAM. Un défaut ajoute donc un temps, une pénalité, à l'accès mémoire. Dans ce qui suivra, le temps d'accès à la RAM sera noté <math>T_m</math>. Fort de ces informations, nous pouvons calculer le temps de latence moyen d'un accès mémoire, qui est la somme du temps d'accès au cache (pour tous les accès mémoire), multiplié par le temps lié aux défauts. On a alors : : <math>T = T_c + \text{Taux de défaut} \times T_m</math> On voit que plus le taux de succès est élevé, plus le temps de latence moyen sera bas, et inversement. Ce qui explique l'influence du taux de succès sur les performances du cache, influence assez importante sur les processeurs actuels. De nos jours, le temps que passe le processeur dans les défauts de cache devient de plus en plus un problème au fil du temps, et gérer correctement le cache est une nécessité, particulièrement sur les processeurs multi-cœurs. Il faut dire que la différence de vitesse entre processeur et mémoire est tellement importante que les défauts de cache sont très lents : alors qu'un succès de cache va prendre entre 1 et 5 cycles d'horloge, un cache miss fera plus dans les 400-1000 cycles d'horloge. Tout ce temps sera du temps de perdu que le processeur aura du mal à mitiger. Autant dire que réduire les défauts de cache est beaucoup plus efficace que d'optimiser les calculs effectués par le processeur (erreur courante chez de nombreux programmeurs, notamment débutants). ===L'impact de la taille du cache sur le taux de défaut et la latence=== Il y a un lien entre taille du cache, taux de défaut, débit binaire et latence moyenne. Globalement, plus un cache est gros, plus il est lent. Simple application de la notion de hiérarchie mémoire vue il y a quelques chapitres. Les raisons à cela sont nombreuses, mais nous ne pouvons pas les aborder ici, car il faudrait que nous sachions comment fonctionne un cache et ce qu'il y a à l'intérieur, ce qui sera vu dans la suite du chapitre. Toujours est-il que la latence moyenne d'un cache assez gros est assez importante. De même, le débit binaire d'un cache diminue avec sa taille, mais dans une moindre mesure. Les petits caches ont donc un gros débit binaire et une faible latence, alors que c'est l'inverse pour les gros caches. Une grande capacité de cache améliore le taux de succès, mais cela se fait au détriment de son temps de latence et de son débit, ce qui fait qu'il y a un compromis assez difficile à trouver entre taille du cache, latence et débit. Il peut arriver qu'augmenter la taille du cache augmente son temps d'accès au point d’entraîner une baisse de performance. Par exemple, les processeurs Nehalem d'Intel ont vus leurs performances dans certains jeux vidéos baisser de 2 à 3 %, malgré de nombreuses améliorations architecturales, parce que la latence du cache L1 avait augmentée de 2 cycles d'horloge. Pour avoir une petite idée du compromis à faire, regardons la relation entre taille du cache et taux de défaut. Il existe une relation approximative entre ces deux variables, appelée la '''loi de puissance des défauts de cache'''. Elle donne le nombre total de défaut de cache en fonction de la taille du cache et de deux autres paramètres. Voici cette loi : : <math>\text{Taux de défauts de cache} \approx K \times \text{Taille du cache}^{- \alpha }</math>, avec <math>K</math> et <math>\alpha</math> deux coefficients qui dépendent du programme exécuté. Le coefficient <math>\alpha</math> est généralement compris entre 0.3 et 0.7, guère plus, et varie suivant le programme exécuté. Précisons que cette loi ne marche que si le cache est assez petit par rapport aux données à utiliser. Pour un cache assez gros et des données très petites, la relation précédente est mise en défaut. Pour s'en rendre compte, il suffit d'étudier le cas extrême où toutes les données nécessaires tiennent dans le cache. Dans ce cas, il n'y a qu'un nombre fixe de défauts de cache : autant qu'il faut charger de données dans le cache. Le nombre de défauts de cache observé dans cette situation n'est autre que le coefficient <math>K</math> de la situation précédente, mais il n'y a aucune dépendance entre taux de défaut et taille du cache. L'origine de cette relation s'explique quand on regarde combien de fois chaque donnée est réutilisée lors de l’exécution d'un programme. La plupart des données finissent par être ré-accédées à un moment ou un autre et il se passe un certain temps entre deux accès à une même donnée. Sur la plupart des programmes, les observations montrent que beaucoup de réutilisations de données se font après un temps très court et qu'inversement, peu de ré-accès se font après un temps inter-accès long. Si on compte le nombre de réutilisation qui ont un temps inter-accès bien précis, on retrouve une loi de puissance identique à celle vue précédemment : : <math>\text{Nombre de réaccès avec un temps inter-accès égal à t} \approx K \times t^{- \beta}</math>, avec t le temps moyen entre deux réutilisations. Le coefficient <math>\beta</math> est ici compris entre 1.7 et 1.3. De manière générale, les coefficients <math>\alpha</math> et <math>\beta</math> sont reliés par la relation <math>\alpha = 1 - \beta</math>, ce qui montre qu'il y a un lien entre les deux relations. Précisons cependant que la loi de puissance précédente ne vaut pas pour tous les programmes informatiques, mais seulement pour la plupart d’entre eux. Il n'est pas rare de trouver quelques programmes pour lesquels les accès aux données sont relativement prédictibles et où une bonne optimisation du code fait que la loi de puissance précédente n'est pas valide. La loi de puissance des défauts de cache peut se démontrer à partir de la relation précédente, sous certaines hypothèses. Si un suppose que le cache est assez petit par rapport aux données, alors les deux relations sont équivalentes. L'idée qui se cache derrière la démonstration est que si le temps entre deux accès à une donnée est trop long, alors la donnée accédée aura plus de chance d'être rapatriée en RAM, ce qui cause un défaut de cache. La chance de rapatriement dépend de la taille du cache, un cache plus gros peut conserver plus de données et a donc un temps avant rapatriement plus long. ==Les lignes de cache et leurs tags== Du point de vue du processeur, les lectures et écritures se font mot mémoire par mot mémoire. Un processeur avec des entiers de 64 bits recoit des données de 64 bits de la part du cache, et y écrit des mots de 64 bits. Mais quand on regarde comment sont stockées les données à l'intérieur du cache, les choses sont différentes. ===Les lignes de cache=== Les données sont mémorisées dans le cache par blocs de plusieurs bytes, d'environ 64 à 256 octets chacun, qui portent le nom de '''lignes de cache'''. Les lignes de cache sont l'unité de stockage que l'on trouve à l'intérieur du cache, mais elles servent aussi d'unité de transaction avec la mémoire RAM. Sur les caches actuels, on transfère les données entre le cache et la RAM ligne de cache par ligne de cache, dans la limite de la taille du bus mémoire. Mais d'autres caches plus anciens permettaient de faire des transferts plus fins. C’est-à-dire qu'on pouvait mettre à jour quelques octets dans une ligne de cache sans avoir à la recopier intégralement depuis ou dans la mémoire RAM. En théorie, on pourrait imaginer des caches où les données sont stockées différemment, où l'unité serait le mot mémoire, par exemple. Par exemple, sur un processeur 64 bits, on aurait une ligne de cache de 64 bits. Cela aurait l'avantage de la simplicité : les transferts entre le processeur et la mémoire serait de même taille, l'intérieur du cache ressemblerait à son interface montrée au processeur. Mais cela aurait quelques défauts qui sont compensés par l'organisation en lignes de cache de grande taille. Le premier avantage des lignes de cache est lié à la localité spatiale, la tendance qu'on les programmes à accéder à des données proches les unes des autres. Des accès mémoires consécutifs ont tendance à se faire à des adresses proches, qui ont de bonnes chances d'être dans la même ligne de cache. Et des accès consécutifs à une même ligne de cache sont plus rapides que des accès à deux lignes distinctes. Une autre raison est tout simplement que cela simplifie considérablement la circuiterie du cache. Pour une capacité identique, il vaut mieux avoir peu de lignes de cache assez grosses, que beaucoup de petites lignes de cache. La raison est que les circuits du cache, comme le décodeur, l'encodeur et autres, ont moins de sorties et sont donc plus simples. ===L'alignement des lignes de cache=== Les lignes de cache sont des blocs de plusieurs dizaines à centaines de bytes, dont la taille est presque toujours une puissance de deux. De plus, les lignes de cache sont alignées en mémoire. Nous avions déjà abordé la notion d'alignement mémoire dans un chapitre précédent, mais le concept d'alignement des lignes de cache est quelque peu différent. Quand nous avions parlé d'alignement auparavant, il s'agissait de l'alignement des données manipulées par le processeur, qui faisait partie du jeu d'instruction du processeur. Ici, nous parlons d'un alignement totalement différent, invisible pour le programmeur, sans lien avec le jeu d’instruction. Voyons de quoi il retourne. Concrètement, cela veut dire que du point de vue du cache, la RAM est découpée en blocs qui font la même taille qu'une ligne de cache, aux positions prédéterminées, sans recouvrement entre les blocs. Par exemple, pour un cache dont les lignes de cache font 256 octets, le premier bloc est à l'adresse 0, le second est 256 octets plus loin, c'est à dire à l'adresse 256, le troisième à l'adresse 512, la quatrième à l'adresse 768, etc. Une ligne de cache de 256 octets contiendra une donnée provenant d'un bloc de RAM de 256 octets, dont l'adresse est systématiquement un multiple de 256. Il n'est pas possible qu'une ligne de cache contienne un bloc de 256 octets dont l'adresse du premier octet serait l'adresse 64, ou l'adresse 32, par exemple. En clair, les adresses de ces blocs sont des multiples de la taille de la ligne de cache, de la taille des blocs. Cela rappelle les contraintes d'alignement vues dans le chapitre "Le modèle mémoire : alignement et boutisme", mais appliquées aux lignes de cache. L'alignement des lignes de cache a des conséquences pratiques pour la conception des caches. Notons qu'il est en théorie possible d'avoir des caches dont les lignes de cache ne sont pas alignées, mais cela poserait des problèmes majeurs. Il serait en effet possible qu'une donnée soit présente dans deux lignes de cache à la fois. Par exemple, prenons le cas où une ligne de cache de 256 commence à l'adresse 64 et une autre ligne de cache commence à l'adresse 0. L'adresse 128 serait dans les deux lignes de cache ! Et cela poserait des problèmes lors des lectures, mais encore plus lors des écritures. C'est pour éviter ce genre de problèmes que les lignes de cache sont alignées avec la mémoire RAM dans tous les caches existants. L'alignement des lignes de cache est une chose que les programmeurs doivent parfois prendre en compte quand ils écrivent du code ultra-optimisé, destiné à des programmes demandant des performances extrêmes. Il arrive que les contraintes d'alignement posent des problèmes. Nous avions vu dans le chapitre sur le boutisme et l'alignement qu'il valait mieux gérer l'alignement des variables des structures de données, pour éviter les accès non-alignés avec le bus mémoire. La même chose est possible, mais pour l'alignement avec des lignes de cache. Typiquement, l'idéal est que, pour une structure de donnée, on puisse en mettre un nombre entier dans une ligne de cache. Ou alors, si la structure est vraiment grande, que celle-ci occupe un nombre entier de lignes de cache. Si ce n'est pas le cas, il y a un risque d'accès non-alignés, c'est à dire qu'une structure se retrouve à cheval sur deux lignes de cache, avec les défauts que cela implique. ===Le tag d'une ligne de cache=== Plus haut, nous avions dit que le cache mémorise, pour chaque ligne de cache, l'adresse RAM associée. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Mais du fait de l'organisation du cache en lignes de cache de grande taille, qui sont de plus alignées en mémoire, il faut nuancer cette affirmation. Le cache ne mémorise pas la totalité de l'adresse, ce qui serait inutile. L'alignement des lignes de cache en RAM fait que les bits de poids faible de l'adresse ne sont pas à prendre en compte pour l'association adresse-ligne de cache. Dans ces conditions, on mémorise seulement la partie utile de l'adresse mémoire correspondante, qui forme ce qu'on appelle le '''tag'''. Le reste de l'adresse indique quelle est la position de la donnée dans la ligne de cache. Par exemple, prenons le cas où le processeur gère des nombres entiers de 64 bits (8 octets) et des lignes de cache de 128 octets : chaque ligne de cache contient donc 16 entiers. Si le processeur veut lire ou écrire un entier bien précis, il doit préciser sa place dans la ligne de cache. Et ce sont les bits de l'adresse mémoire non-inclus dans le cache qui permettent de faire ça. En clair, une adresse mémoire à lire/écrire est interprété par le cache comme la concaténation d'un tag et de la position de la donnée dans la ligne de cache correspondante. [[File:Adressage d'un cache totalement associatif.png|centre|vignette|upright=2|Adressage d'un cache totalement associatif]] Le cache est donc une grande table de correspondance entre tags et lignes de cache. Lors d'un accès mémoire, le cache extrait le tag de l'adresse à lire ou écrire, et le compare avec les tags de chaque ligne de cache. Si une ligne contient ce tag, alors c'est que cette ligne correspond à l'adresse, et c'est un défaut de cache sinon. Lors d'un succès de cache, la ligne de cache est lue depuis le cache et envoyée à un multiplexeur qui sélectionne la donnée à lire dans la ligne de cache. Le fonctionnement est similaire pour une écriture : la donnée à écrire passe dans un démultiplexeur, qui envoie la donnée au bon endroit dans la ligne de cache sélectionnée. [[File:Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.png|centre|vignette|upright=2|Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.]] ===Le contenu d'une ligne de cache=== Dans ce qui va suivre, nous allons considérer que chaque ligne de cache mémorise son tag, les données de la ligne de cache proprement dit, et quelques bits de contrôle annexes qui varient suivant le cache considéré. [[File:Tag d'une ligne de cache.png|centre|vignette|upright=2|Tag d'une ligne de cache.]] Les caches modernes incluent de nombreux bits de contrôle, mais deux d'entre eux sont communs à presque tous les caches modernes : le bit ''Dirty'' et le bit ''Valid''. Le '''bit ''Valid''''' indique si la ligne de cache contient des données valides ou non. Si le bit ''Valid'' est à 0, la ligne de cache est en état valide, à savoir qu'elle contient des données et n'est pas vide. Par contre, si ce bit est à 1, la ligne de cache est invalide et son contenu ne peut pas être lu ou écrit. L'utilité de ce bit est qu'il permet d'effacer une ligne de cache très rapidement : il suffit de mettre ce bit à 0. Il existe des situations où le cache doit être effacé, on dit alors qu'il est invalidé. Une section de ce chapitre sera dédié à l'invalidation du cache. Le '''bit ''Dirty''''' indique qu'une ligne de cache a été modifiée. Par modifiée, on veut dire que le processeur a écrit dedans, qu'il a modifié la ligne de cache. Mais attention : si la donnée a été modifiée dans le cache, la modification n'est pas forcément propagée en mémoire RAM. Le bit ''dirty'' indique si c'est le cas, si l'écriture a été propagée en mémoire RAM. Il précise que la ligne de cache contient des données modifiées, alors que la RAM a des données initiales non-modifiées. Une ligne de cache avec un bit ''dirty'' à 1 est dite ''dirty'', par métonymie. Nous verrons cela en détail dans la section sur les caches ''write-back'' et ''write-through''. Les caches modernes ajoutent des '''bits de détection/correction d'erreur''' dans les bits de contrôle. Pour rappel, les codes de détection/correction d'erreur permettent de se prémunir contre des erreurs matérielles, qui corrompent les données stockées dans une mémoire, ici une mémoire cache. Ils ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Nous reviendrons dessus dans une section ultérieur de ce chapitre. Sur certains caches assez anciens, on pouvait transférer les lignes de caches morceaux par morceaux. Ces caches avaient des lignes de cache divisées en sous-secteurs, ces sous-secteurs étant des morceaux de ligne de cache qu'on pouvait charger indépendamment les uns des autres (mais qui sont consécutifs en RAM). Chaque secteur avait ses propres bits de contrôle, mais le tag était commun à tous les secteurs. [[File:Cache à secteurs.png|centre|vignette|upright=2.5|Cache à secteurs.]] : Dans ce qui va suivre, le terme "ligne de cache" désignera soit un bloc de données copiées depuis la RAM d'une taille de 64/128/256/... octets, soit la concaténation de ces données avec le tag et des bits de contrôle. Les deux définitions ne sont pas équivalentes, mais l'usage a entériné cet abus de langage. Et il faut avouer que cela rend les explications du chapitre plus simples. ==Les instructions de contrôle du cache== Plus haut, nous avions dit que le cache est totalement transparent du point de vue du programmeur. Le cache contient des copies de données en RAM, le programmeur n'a rien à faire pour utiliser le cache correctement. Mais la réalité est que pour des raisons diverses, des processeurs incorporent des '''instructions de contrôle du cache'''. Il s'agit d’instructions qui agissent sur le contenu du cache. Elles existent pour des raisons diverses qu'on détaillera plus bas, mais il s'agit globalement d'une question de performances ou de nécessité pour le système d'exploitation. ===Les instructions de préchargement=== La première instruction de contrôle du cache est une '''instruction de préchargement''', qui demande à charger un bloc de données dans le cache. Elle prend en opérande une adresse mémoire, et le contenu de cette adresse est chargé dans une ligne de cache. Bien sûr, des contraintes d'alignement sont à prendre en compte : on charge un bloc de la même taille qu'une ligne de cache, aligné en mémoire sur la taille du bloc, qui contient l'adresse. L'instruction de préchargement n'est utile que si l'instruction est exécutée bien avant que la donnée ne soit utilisée/lue/écrite. Cela permet de charger une donnée dans le cache à l'avance, d'où le nom de préchargement donné à cette technique. Mais les processeurs modernes gérent des techniques de préchargement automatique, qui ne requièrent pas d'instructions de préchargement. Le préchargement automatique et les instructions de préchargement sont deux solutions complémentaires, mais qui peuvent se marcher sur les pieds. Nous en reparlerons dans le prochain chapitre, qui sera dédié au préchargement automatique. Il faut noter que les instructions de préchargement peuvent être ignorées par le processeur. Sous certaines conditions, le processeur peut décider que l'instruction de préchargement ne sera pas exécutée. Par exemple, il ne va pas précharger une donnée déjà présente dans le cache. Ou encore, si le bus mémoire est occupé, il ne va pas exécuter le préchargement, par manque de ressources matérielles. ===Les instructions d'invalidation et de ''flush''=== Les instructions ''flush'' regroupent deux types d'instructions qui sont souvent utilisées en même temps. Il s'agit des instructions d'invalidation et de nettoyage (''clean''). Les deux termes proviennent de la terminologie ARM, il n'y a pas de terminologie standardisé pour les noms de ces instructions. Dans les grandes lignes, elles permettent de vider le cache, à savoir de rapatrier son contenu en RAM et de réinitialiser le cache à zéro. Elles sont utilisées par le système d'exploitation lors des commutations de contexte, à savoir quand on passe d'un programme à un autre. Elles sont aussi utilisées lors des appels systèmes et routines d'interruption/exception. L'idée est de vider le cache avant d'exécuter un nouveau programme ou une nouvelle routine. Le nouveau programme aura accès à un cache tout propre, les données de l'ancien programme auront été retirée du cache. Les '''instructions ''clean''''' recopient le contenu de la ligne de cache en RAM. Elles forcent la recopie immédiatement de la ligne de cache en mémoire RAM. Pour faire leur travail, elle vérifient si la ligne de cache a été modifiée, avant de la recopier en RAM. Et pour cela, ils vérifient le bit de contrôle ''dirty'', qui est mis à 1 après une première écriture. Si ce bit est à 0, alors pas besoin de recopier la ligne de cache : elle n'a pas été modifiée, la RAM a déjà la bonne copie. Mais s'il est à 1, le cache et la RAM n'ont pas le même contenu, la recopie s'exécute. Les '''instructions d'invalidation''' permettent d'invalider une ligne de cache, à savoir d'effacer son contenu. Nous verrons à quoi servent ces instructions dans la section sur les changement de processus. Invalider une ligne de cache est une opération optimisée : le cache n'est en réalité pas réellement effacé. À la place, le bit ''Valid'' de chaque ligne de cache est juste mis à 0. Il faut noter que l'invalidation efface les lignes de cache sans se préoccuper de leur contenu. Elle se moque qu'une ligne de cache contienne une donnée modifiée, ''dirty'' ou quoique ce soit : la ligne de cache est effacée, point. Il est possible d'invalider une ligne de cache en fournissant une adresse mémoire, mais il est aussi possible d'invalider le cache tout entier. Le choix entre les deux dépend du mode d'adressage de l'instruction d'invalidation. Parfois, il existe une instruction séparée pour invalider tout le cache, et une autre pour invalider une ligne de cache bien précise. Des instructions séparées sont parfois disponibles pour invalider les caches de données et d'instructions, parfois aussi la TLB (un cache qu'on verra dans quelques chapitres). Il est possible de n'invalider que le cache L1, voire le cache L2. Il faut noter que l'invalidation efface tout le cache, mais ne se préoccupe pas de vérifier si les données ont été modifiées dans le cache. Pour certains caches, comme le cache d'instruction, ce n'est pas un problème, vu qu'il est en "lecture seule". Mais pour les caches de données, les données modifiées sont perdues en cas d'invalidation. Heureusement, il existe des instructions d'invalidation qui fusionnent une instruction ''clean'' et une instruction d'invalidation. Il s'agit d''''instructions d'invalidation spéciales'''. ===Les instructions d'optimisation : instructions non-temporelles et écritures optimisées=== Les '''instructions mémoire non-temporelles''' contournent complètement le cache. Par exemple, une lecture peut lire une donnée, mais celle-ci ne sera pas chargée dans le cache, elle passe directement de la RAM vers les registres. Une section entière de ce chapitre sera dédiée au contournement du cache, à savoir aux situations où les accès mémoire doivent passer directement du processeur à la RAM sans passer par le cache. D'autres instructions assez rares incorporent des indications pour le cache. Par exemple, l'instruction ''load last'' des processeurs POWER PC implique que la donnée ne sera utilisée qu'une seule fois. Elle est donc chargée dans le cache, mais la ligne de cache est configurée de manière à être remplacée très rapidement, typiquement avec une valeur de LRU/LFU adéquate. La donnée est bien chargée dans le cache, au cas où elle doive être relue suite à une mauvaise prédiction de branchement ou autre, chose qu'une lecture non-temporelle (qui contourne le cache) ne fait pas. Des indications de ce type sont appelées des '''''cache hint'''''. L''''instruction ''flush''''' permet de préciser qu'une ligne de cache contient une donnée inutile, qui ne sera pas réutilisée par le programme. Pas besoin de la conserver dans le cache, elle peut laisser sa place à des données plus utiles. Or, sans indication, les algorithmes de remplacement d'une ligne de cache risquent de conserver cette donnée trop longtemps, ce qui entraine une certaine pollution du cache par des données inutiles. Une autre instruction est elle beaucoup plus importante : celle de '''pré-allocation sur écriture'''. Elle sert dans le cas où une ligne de cache est complétement écrite. Par exemple, imaginons qu'on veuille écrire dans une portion de mémoire. Si celle-ci n'est pas dans le cache, le processeur va charger une ligne de cache complète depuis la RAM, écrire dans la ligne de cache, puis recopier la ligne de cache modifiée en mémoire RAM. Une écriture en RAM demande donc de faire une lecture et une écriture. Mais les instructions de pré-allocation sur écriture permettent de prévenir qu'une ligne de cache sera intégralement écrite, et qu'il n'y a donc pas besoin de lire celle-ci depuis la RAM. Notons que l'instruction d'écriture qui suit n'est pas une écriture non-temporelle, vu que les données sont écrites dans la ligne de cache, qui est ensuite envoyée en mémoire RAM dès que nécessaire. De plus, les données écrites peuvent ensuite être relue depuis le cache si nécessaire. Enfin, certains processeurs MIPS incorporent une instruction pour modifier le tag d'une ligne de cache. Elles servent à optimiser les copies mémoire, à savoir quand on copie un bloc de données d'un endroit à un autre. L'idée est de charger le bloc de données dans le cache avec une instruction LOAD/PREFETCH, de modifier le tag pour qu'il pointe vers l'adresse à écrire, et de laisser faire le cache pour que l'écriture se fasse en RAM. Mais les contraintes pour utiliser cette instruction sont assez drastiques : les données doivent être alignées sur la taille d'une ligne de cache, le bloc de départ et d'arrivée (l'original versus la copie) ne doivent pas se recouvrir, etc. ==L'associativité des caches et leur adressage implicite== Lorsqu'on souhaite accéder au cache, il faut trouver quelle est la ligne de cache dont le tag correspond à l'adresse demandée. On peut classifier les caches selon leur stratégie de recherche de la ligne correspondante en trois types de caches : totalement associatifs, directement adressés (''direct mapped'') et associatifs par voie. ===Les caches totalement associatifs=== Avec les caches totalement associatifs, toute donnée chargée depuis la mémoire peut être placée dans n'importe quelle ligne de cache, sans aucune restriction. Ces caches ont un taux de succès très élevé, quand on les compare aux autres caches. [[File:Cache totalement associatif.png|centre|vignette|upright=2|Cache totalement associatif.]] Concevoir un cache totalement associatif peut se faire de deux grandes manières différentes. La première consiste tout simplement à combiner une mémoire associative avec une mémoire RAM, en ajoutant éventuellement quelques circuits annexes. La mémoire associative mémorise les tags, alors que la mémoire RAM mémorise les données de la ligne de cache, éventuellement avec quelques bits de contrôle. La ligne de cache est stockée à une adresse A dans la mémoire RAM et son tag est stocké à la même adresse, mais dans la mémoire CAM. Ce faisant, quand on envoie le tag à la mémoire CAM, elle renvoie l'adresse de la ligne de cache dans la mémoire RAM. Cette adresse est alors envoyée directement sur le bus d'adresse de la RAM, et la lecture est effectuée automatiquement. Il faut ajouter quelques circuits annexes pour garantir que les écritures se passent correctement dans les deux mémoires, mais rien de bien terrible. [[File:Cache fabriqué avec une mémoire associative et une RAM.png|centre|vignette|upright=3|Cache fabriqué avec une mémoire associative et une RAM]] Il est cependant possible d'optimiser un tel cache, en fusionnant la mémoire CAM et la mémoire RAM, afin d'éliminer des circuits redondants. Pour comprendre pourquoi, rappelons que les mémoires CAM sont composées d'un plan mémoire, d'un paquet de comparateurs et d'un encodeur. Quant à la mémoire RAM, elle est composée d'un décodeur connecté au plan mémoire. En mettant une CAM suivie d'une RAM, on a un encodeur dont l'entrée est envoyée à un décodeur. [[File:Cache totalement associatif naif.png|centre|vignette|upright=3|Cache totalement associatif naif]] Or, le décodeur réalise l'opération inverse de l'encodeur, ce qui fait que mettre les deux composants à la suite ne sert à rien. On peut donc retirer l'encodeur et le décodeur, et envoyer directement les résultats des comparateurs sur les entrées de commande du plan mémoire de la RAM. [[File:Cache totalement associatif optimisé.png|centre|vignette|upright=2|Cache totalement associatif optimisé]] Avec cette méthode, les circuits du cache ressemblent à ce qui illustré ci-dessous. Le tag est envoyé à chaque ligne de cache. Le tag envoyé est alors comparé avec le Tag contenu dans chaque ligne de cache, comme c'est le cas sur les mémoires associatives. Si une ligne de cache matche avec le tag envoyé en entrée, la ligne pour laquelle il y a eu une égalité est alors connectée sur les lignes de bit (''bitlines''). Cela est réalisé par un circuit commandé par le comparateur de la ligne de cache. Il ne reste plus qu'à sélectionner la portion de la ligne de cache qui nous intéresse, grâce à un paquet de multiplexeurs. Cela permet d'effectuer une lecture ou écriture, mais il faut aussi préciser si il y a eu un défaut de cache ou un succès. Un succès de cache a lieu quand au moins des comparaisons est positive, alors que c'est un défaut de cache sinon. En clair, détecter un succès de cache demande juste de connecter une porte OU à plusieurs entrées à tous les comparateurs. [[File:Organisation générale d'un cache totalement associatif.png|centre|vignette|upright=2|Organisation générale d'un cache totalement associatif.]] ===Les caches directement adressés=== Les caches directement adressés peuvent être vus comme un cache totalement associatif auquel on aurait ajouté des restrictions assez drastiques. Plus haut, on a vu qu'un cache totalement adressé est équivalent à la combinaison d'une CAM avec une RAM. La mémoire CAM prend en entrée un Tag et traduit celui-ci en une adresse qui commande la mémoire RAM interne au cache. Dans ce qui suit, l'adresse interne au cache sera appelé l''''indice''' pour éviter toute confusion. [[File:Cache hash table - 2.png|centre|vignette|upright=2|Fonctionnement interne du cache, expliquée sous forme abstraite, en utilisant la notion d'indice interne au cache.]] Les caches directement adressés cherchent à remplacer la mémoire CAM par un circuit combinatoire. Ce circuit traduit le Tag en indice, mais est beaucoup plus simple qu'une mémoire CAM. Mais qui dit circuit plus simple dit circuit plus limité. Un circuit combinatoire n'est pas aussi versatile que ce qui est permis avec une mémoire CAM. En conséquence, une restriction majeure apparait : toute adresse mémoire est associée dans une ligne de cache prédéfinie, toujours la même. L'association entre ligne de cache et adresse mémoire est faite par le circuit combinatoire, et ne peut pas changer. Les concepteurs de caches s'arrangent pour que des adresses consécutives en mémoire RAM occupent des lignes de cache consécutives, par souci de simplicité. Tout se passe comme suit la mémoire RAM était découpés en blocs de la même taille que le cache. La première adresse du bloc est associée à la première ligne de cache (celle d'indice 0), la seconde adresse est associée à la seconde adresse du_ bloc, et ainsi de suite. Le tout est illustré ci-dessous. [[File:Cache adressé directement.png|centre|vignette|upright=2|Cache adressé directement.]] Avec cette contrainte, le circuit de traduction de l'adresse en adresse mémoire pour la RAM interne au cache est drastiquement simplifié, et disparait même. Une partie de l'adresse mémoire sert à indiquer la position de la donnée dans le cache, le reste de l'adresse sert encode le tag et la position de la donnée dans le ligne de cache. [[File:Cache line.png|centre|vignette|upright=2|Adresse d'une ligne de cache sur un cache adressé directement.]] Un cache directement adressé est conçu avec une RAM, un comparateur, et un paquet de multiplexeurs. En général, la mémoire RAM stocke les lignes de caches complète. Il arrive que l'on utilise deux mémoires RAM : une pour les tags et une pour les données, mais cette technique augmente le nombre de circuits et de portes logiques nécessaires, ce qui réduit la capacité du cache. L'index à lire/écrire est envoyé sur l'entrée d'adresse de la RAM, la RAM réagit en mettant la ligne de cache sur sa sortie de donnée. Sur cette sortie, un comparateur compare le tag de la ligne de cache lue avec le tag de l'adresse à lire ou écrire. On saura alors si on doit faire face à un défaut de cache. Ensuite, un multiplexeur récupère la donnée à lire/écrire. [[File:Direct mapped cache - french.png|centre|vignette|upright=2|Cache directement adressé.]] L'accès à un cache directement adressé a l'avantage d'être très rapide vu qu'il suffit de vérifier une seule ligne de cache : celle prédéfinie. Mais ces caches ne sont cependant pas sans défauts. Vu que le cache est plus petit que la mémoire, certaines adresses mémoires se partagent la même ligne de cache. Si le processeur a besoin d’accéder fréquemment à ces adresses, chaque accès à une adresse supprimera l'autre du cache : tout accès à l'ancienne adresse se soldera par un défaut de cache. Ce genre de défauts de cache causés par le fait que deux adresses mémoires ne peuvent utiliser la même ligne de cache s'appelle un '''défaut par conflit''' (''conflict miss''). Les défauts par conflit n'existent pas sur les caches totalement associatifs. En conséquence, le taux de succès des caches directement adressés est assez faible comparé aux autres caches. [[File:Cache Block Basic Conflict.svg|centre|vignette|upright=1.5|Exemple de ''Conflict Miss''.]] ===Les caches associatifs par voie=== Les caches associatifs par voie sont un compromis entre les caches directement adressés et les caches totalement associatifs. Pour simplifier, ces caches sont composés de plusieurs caches directement adressés accessibles en parallèle, chaque cache/RAM étant appelé une '''voie'''. Avec ces caches, toute adresse mémoire en RAM est associée à une ligne de cache dans chaque voie. [[File:Cache associatif par voie.png|centre|vignette|upright=2|Cache associatif par voie.]] Le schéma ci-dessous compare un cache directement adressé et un cache associatif à deux voies. On voit que chaque adresse est associée à une ligne de cache bien précise avec un cache directement dressé, et à deux lignes de cache avec un cache associatif à deux voies. L'adresse sera associée à 4 lignes de cache sur un cache associatif à 4 voies, à 8 lignes pour un cache à 8 voies, etc. L'ensemble des lignes de cache associées à une adresse est appelé un '''ensemble'''. [[File:Cache Fill.svg|centre|vignette|upright=2|Comparaison entre un cache directement adressé et un cache associatif à deux voies.]] Sur ces caches, toute adresse est découpée en trois parties : un tag, un index, et un décalage, comme sur les caches directement adressés. Comme vous pouvez le voir, l'organisation est identique à celle d'un cache totalement associatif, à part que chaque ensemble tag-ligne de cache est remplacé par une mémoire RAM qui en contient plusieurs. [[File:Implémentation d'un cache associatif par voie.png|centre|vignette|upright=2|Implémentation d'un cache associatif par voie.]] Le risque de conflits d'accès au cache est donc réduit sur un cache associatif à plusieurs voies, et il est d'autant plus réduit que le cache a de voies. Par contre, leur conception interne fait qu'ils ont un temps d'accès légèrement élevé que les caches directement adressés. Les caches associatifs par voie ont donc un taux de succès et un temps d'accès intermédiaire, situé entre les caches directement adressés et totalement associatifs. Ils sont une sorte de compromis entre réduction des défaut par conflits d'accès au cache et temps d'accès, et complexité des circuits. ==Les optimisations des caches associatifs par voie== Les caches partiellement associatifs regroupent les caches associatifs par voie et directement adressés, ainsi que leurs variantes. En clair : tous les caches qui ne sont pas totalement associatifs. Ils peuvent être optimisés de nombreuses manières, que ce soit pour gagner en performance ou pour économiser de l’énergie. Dans cette section, nous allons voir quelles sont ces optimisations. ===Les caches pseudo-associatifs=== Les caches adressés par voie contiennent une mémoire SRAM par voie. En théorie, les voies sont accédées en parallèles, en même temps, afin de voir si l'on a un succès de cache ou un défaut. Les '''caches pseudo-associatifs''' sont identiques aux caches associatifs par voie, si ce n'est qu'ils vérifient chaque voie une par une. Ils ont été utilisés sur des processeurs commerciaux, un exemple étant l'IBM 370. Là encore, on perd en performance pour gagner en consommation d'énergie. Le temps d'accès dans le meilleur des cas est plus faible pour les caches pseudo-associatifs, mais le pire des cas teste tous les caches avant de tomber sur le bon. Les performances sont donc réduites. Mais la consommation énergétique est meilleure, vu qu'on ne vérifie pas forcément toutes les voies en parallèle. On teste la première voie, éventuellement la seconde, peut-être la troisième, etc. Mais dans le cas général, on ne teste qu'une partie des voies, pas toutes, ce qui donne un gain en termes d'énergie. L'implémentation de caches de ce genre demande que l'on parcoure les voies une par une, en commençant de la première jusqu'à la dernière. Pour cela, un simple compteur suffit. Suivant la valeur du compteur, la voie associée est activée puis accédée. Toute la complexité revient à ajouter un circuit qui prend la valeur du compteur, et active la voie associée, lance un accès mémoire dessus. Vu que les voies sont chacune des caches ''direct mapped'', il suffit pour cela de geler les entrées d'adresse, soit en les déconnectant, soit en utilisant du ''clock gating'' ou de l'évaluation gardée. Les détails d'implémentation, non-cités ici, varient selon le cache. ===La prédiction de voie=== Pour réduire le temps d'accès des caches pseudo-associatifs, certains chercheurs ont inventé la '''prédiction de voie''', qui consiste à faire des paris sur la prochaine voie accédée. L'idée est d'accéder à la voie qui contient la donnée voulue du premier coup, en lisant celle-ci en priorité. Dans son implémentation la plus simple, le cache reste un cache pseudo-associatif. Lors d'un accès au cache, les voies sont toutes parcoures une par une. Par contre, les voies ne sont donc pas parcourues de la première vers la dernière, mais dans un ordre différent. Cette technique permet de mettre en veille les voies sur lesquels le processeur n'a pas parié, ce qui permet de diminuer la consommation énergétique du processeur. C'est plus efficace que d'aller lire plusieurs données dans des voies différentes et de n'en garder qu'une. L'implémentation est assez simple : il suffit d'ajouter un circuit de prédiction de voie,relié au compteur de voie. Une amélioration de la technique fait fonctionner le cache comme un intermédiaire entre cache pseudo-associatif et associatif par voies. L'idée est de chercher la voie prédite en premier, puis de chercher dans toutes les voies en parallèle en cas de défaut de cache. Au lieu d'attendre que les comparaisons de tags donnent leur résultat, le processeur sélectionne automatiquement une voie et configure les multiplexeurs à l'avance. Si le processeur ne se trompe pas, le processeur accède à la donnée plus tôt que prévu. S'il se trompe, le processeur annule la lecture effectuée en avance et recommence en faisant un accès en parallèle aux autres voies. Le compromis entre performance et consommation d'énergie est alors différent. On économise de l'énergie par rapport à un cache associatif par voie, au prix d'une petite perte de performance (doublement des temps d'accès). Mais par rapport à un cache pseudo-associatif, l'économie d'énergie est bien moindre, au prix d'un gain en performance assez manifeste. Prédire quelle voie sera la bonne est assez simple. En vertu du principe de localité, les accès futurs ont des chances de tomber dans les voies les plus fréquemment utilisées ou dans celle plus récemment utilisée. Il suffit de retenir la voie la plus récemment accédée dans un registre, qui sera utilisée comme prédiction. Pour vérifier que la prédiction est correcte, il suffit de comparer le registre et le résultat obtenu après vérification des tags. Cependant, on peut complexifier l'implémentation pour prendre en compte l'adresse à lire/écrire, l'instruction à l'origine de l'accès mémoire ou tout autre paramètre utile. Par exemple, des instructions différentes ont tendance à aller chercher leurs données dans des ensembles différents et la voie à choisir n'est pas la même. Pour cela, il suffit d'utiliser un cache pour stocker la correspondance instruction - voie. Pour plus de simplicité, la mémoire cache des prédictions est parfois remplacée par une RAM, qui est adressée : * soit par le program counter de l'instruction à l'origine de l'accès (en réalité, seulement quelques bits de poids faible de l'adresse) ; * soit par l'adresse à accéder (là encore, quelques bits de poids faible) ; * soit (pour les modes d'adressage qui utilisent un registre de base et un décalage) par un XOR entre les bits de poids faible de l'adresse de base et le décalage ; * soit par autre chose. ===La mise en veille sélective des voies=== Les caches associatifs ont tendance à utiliser beaucoup d'énergie, même quand on n'y accède pas. Aussi, certains processeurs détectent quand le cache est peu utilisé et en profitent pour mettre en veille les voies inutilisées. Vous vous demandez certainement ce qui se passe quand une donnée à lire/écrire est dans une voie désactivée. La réponse est que le cache détecte cette situation, car elle déclenche un succès de cache. Les ''tags'' ne sont en effet pas désactivés, seules les données sont mises en veille. L'implémentation est plus simple sur les caches qui séparent les tags et les données dans deux RAM différentes. Cette optimisation marche surtout sur les gros caches, qui ont des chances d'avoir une portion significative d’inutilisée (pas assez de données pour les remplir), donc généralement les caches L3/L4. Par exemple, les processeurs d'Intel de microarchitecture Ivy Bridge disposent d'un cache de 8 mébioctets à 16 voies, qu'ils peuvent faire passer à 512 kibioctets si le besoin s'en fait sentir. Quand ces processeurs détectent une faible activité, ils mettent en veille 14 voies et n'en gardent que 2 d'actives. Évidemment, les 14 voies sont vidées avant d'être mises en veille, afin qu'une aucune donnée ne soit perdue. ===Les caches ''skew-associative''=== Vous aurez remarqué que dans une voie, les lignes sont accédées en adressage direct : les défauts par conflit sont possibles sur un cache associatif par voie. Pour éviter cela, certains chercheurs ont créé des '''caches ''skew associative''''' (ou associatifs à biais). Pour faire simple, les index des lignes de cache subissent un petit traitement avant d'être utilisés. Le traitement en question est différent suivant la voie de destination, histoire que deux adresses mémoires avec des index identiques donnent des index différents après traitement. Le traitement en question est souvent une permutation des bits de l'index, qui est différente suivant la voie prise, ou un simple XOR avec un nombre qui dépend de la voie. [[File:Implémentation d'un cache skew associative.jpg|centre|vignette|upright=2|Implémentation d'un cache skew associative.]] ==Les caches splittés (''phased caches'')== Dans cette section, nous allons voir les '''caches splittés''' (''phased caches''), qui sont une variante des caches ''direct-mapped'', dans lequel le cache est accédé en deux étapes consécutives. Il ne s'agit pas des caches pipelinés, que nous verrons dans le chapitre sur les processeurs pipélinés, mais laissons cela à plus tard. Il est possible d'appliquer la même méthode sur un cache associatif par voie, mais il y a des méthodes plus simples, qui permettent là aussi d’accéder au cache en plusieurs étapes consécutives. L'idée est de scinder le cache en deux : une mémoire pour les tags, une autre pour les données de la ligne de cache. Les bits de contrôle peuvent être mis dans l'une ou l'autre SRAM, mais ils sont souvent mis dans la RAM pour les tags. En faisant cela, quelques optimisations deviennent possibles, afin de réduire la consommation énergétique en contrepartie d'une perte de performance. La technique s'implémente différemment pour les caches totalement associatifs et partiellement associatifs. Les caches totalement associatifs splittés sont ceux formés en combinant un cache associatif avec une CAM et une RAM combinée. On envoie l'adresse à lire/écrire à la mémoire associative, elle répond en envoyant une adresse à la mémoire RAM. L'accès se fait donc en deux temps, avec l'adresse dans la RAM comme intermédiaire. Il est possible de séparer physiquement les deux étapes en insérant un registre entre la CAM et la RAM, ce qui permet aussi de pipeliner l'accès. Mais c'est rarement fait en pratique, car le cout en circuit d'une mémoire CAM est trop important. L'équivalent pour un cache totalement associatif optimisé, sans CAM et RAM séparée, est trop gourmande en interconnexions pour être implémentée. Les caches totalement associatifs splittés sont donc très rares, l'auteur ne connait aucun exemple de processeur avec un tel cache. Il existe une technique équivalente pour les caches ''direct-mapped'', mais elle demande une certaine modification du cache. Dans les caches ''direct-mapped'' non-splittés, on trouve une mémoire SRAM dont chaque mot mémoire contient une ligne de cache entière, tag inclus. Dans leurs versions splittés, la SRAM est séparée en deux : une pour les tags, une autre pour les données. Précisons qu'il s'agit bien de deux mémoires SRAM adressables. L'adresse à laquelle accéder est envoyée à la SRAM des tags, puis ensuite à la SRAM des données si besoin. L'idée est d’accéder aux tags pour déterminer s'il y a un succès de cache ou un défaut, et ensuite d'accéder aux données. On n’accède pas aux données en parallèle des tags. Faire cela est évidemment plus lent. En cas de défaut de cache, le temps d'accès est similaire : le tag ne correspond pas, on n'accède pas à la SRAM pour les données. Par contre, vu qu'on n'a pas activé la SRAM pour les données, on économise un peu d'énergie, ce qui réduit la consommation d'énergie. En cas de succès de cache, on accède à la SRAM pour les tags, puis à celle pour les données. Pas d'économie d'énergie à l'horizon, sans compter que le temps d'accès augmente : on accède au cache en deux étapes au lieu de faire les deux accès en parallèle. [[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]] Précisons cependant que ce design peut avoir deux avantages en termes de performance. Premièrement, le temps d'accès au cache est légèrement amélioré en cas de défaut de cache. En effet, la SRAM des tags est assez petite, idem pour celle des données. Leur temps d'accès est donc plus faible que pour une grosse SRAM contenant données et tags. Le gain en temps d'accès est donc un avantage, qui ne se manifeste surtout en cas de défaut de cache. Un autre avantage est que l'accès au cache se pipeline plus facilement, ce qui fait qu'on peut effectuer plusieurs accès simultanés au cache. Mais nous verrons cela dans quelques chapitres. ===Le contrôleur de cache 82385 pour les CPU Intel 386=== Il est important de noter que la séparation entre tags et RAM peut être telle que les deux ne sont pas sur la même puce de silicium ! Voire que les deux sont séparés du processeur ! C'était le cas quand les mémoires caches ont été introduites sur les processeurs grand public, notamment sur les premiers processeurs Intel. La miniaturisation n'avait pas avancé au point où placer un cache dans le processeur était possible. Sur le processeur 386 d'Intel, le cache était un cache splitté, séparé du processeur. Concrètement, le processeur i386 était couplé à un contrôleur de cache Intel 82385 et une mémoire SRAM. Le 82385 contenait les ''tags'' et les bits de contrôle, la SRAM contenait les données, les lignes de cache. Un point important est que les lignes de cache faisaient seulement 32 bits/4 octets, pas plus ! On était loin des lignes de cache actuelles, faisant 64 octets/512 bits. Mais c'était beaucoup plus pratique, vu que le bus système faisait 32 bits de large, idem pour l'interface avec le processeur. Pour intégrer un cache facultatifs (certains ordinateurs 'en passaient). Le schéma ci-dessous montre comment le cache s'intégrait avec le bus système. Pour le bus de commande, le cache servait d'intermédiaire : il recevait les commandes et et les filtrait suivant les succès/défauts de cache. En cas de succès de cache, les commandes de lecture n'étaient pas envoyées à la mémoire RAM. Les adresses étaient transmises à la fois au cache et au bus système (avec un registre entre le bus système et le processeur). Le bus de donnée était lui connecté à la mémoire SRAM et au processeur, avec des MUX/DEMUX pour faire le choix de la source des lectures. [[File:Controleur de cache 82385 pour l'Intel 386.png|centre|vignette|upright=2.5|Contrôleur de cache 82385 pour l'Intel 386]] Le 82385 surveillait ce qui se passait sur le bus et répondait à la place de la RAM pour certaines lectures. C'était un intermédiaire assez passif, qui se contenait de répondre aux succès et défauts en lecture. Le cache était un cache ''write through'' un peu particulier. En cas de succès de cache pour une écriture, le cache met à jour sa ligne de cache et propage l'écriture en mémoire RAM. Par contre, si une écriture fait un défaut de cache, la donnée n'est pas écrite dans le cache. Le seul moyen pour copier une donnée dans le cache était un défaut pour une lecture. Le 82385 pouvait commander soit un cache ''direct mapped'', soit associatif à deux voies. Le choix entre les deux était le fait d'une entrée : la mettre à 0 indiquait un cache ''direct mapped'', la mettre à 1 forçait un cache à deux voies. La différence entre les deux est que le 82385 était relié à une mémoire SRAM avec un cache ''direct mapped'', deux SRAM pour deux voies. Pour avoir un cache associatif à deux voies, le 82385 devrait gérer deux signaux ''chip select'' pour activer chaque SRAM/voie suivant les besoins. Il avait précisément quatre signaux CS : deux par SRAM, un pour les lectures, un pour les écritures. Notons que les SRAM utilisées devaient être des SRAM avec un bus de données de 32 bits, des SRAM 16 ou 8 bits ne fonctionnaient pas. Le 82385 rajoutait 4 sorties, pour masquer chaque octet dans ces 32 bits. [[File:Interface entre le 82385 et la SRAM du cache.png|centre|vignette|upright=2|Interface entre le 82385 et la SRAM du cache. Beaucoup d'entrées et de sorties liées au bus d'adresse ne sont pas représentées.]] Il gérait aussi les accès mémoire non-cacheable, à savoir des accès mémoire qui ne doivent pas être pris en compte par le cache. Il considérait certains accès mémoire comme "à ne pas cacher". Notamment, les accès mémoire à une entré-esortie ne sont pas cachés. Pour rappel, le processeur utilisait un espace d'adressage séparé pour les entrées-sorties, et utilisait donc un bit IO, qui était utilisé par le 82385 pour savoir si l'accès mémoire doit être caché ou non. Il en est de même pour les accès ayant lieu lors d'une interruption, qui ne passent pas par le cache. Mais au-delà de cette inhibition automatique du cache, le 82385 avait une entrée NCA (''Non Cacheable Access'') : le cache était "désactivé" quand cette entrée était à 1. C'est un peu une sorte de ''chip select'' pour le 82385, limitée aux accès mémoire. Cette entrée permettait de programmer des intervalles d'adresse auxquels ne pas répondre, en utilisant des circuits de décodage d'adresse adaptés. Il avait aussi une entrée X16, qui permettait d'identifier les accès soit à un composant 16 bits. De tels accès ne doivent pas être mis en cache, sans doute parce que cela ne collait pas avec la taille des lignes de cache (32 bits). Et cette entrée permettait d'inhiber ces accès 16 bits d'agir sur le cache, en utilisant le bit du bus de commande adéquat. Le 82385 pouvait être intégré dans un système à deux processeurs, voire plus. Pour cela, chaque processeur avait son propre 82385 et sa SRAM rien qu'à lui. Il n'y avait pas de cache partagé entre les deux processeurs. Par contre, les deux caches étaient reliés au même bus système. Pour qu'ils ne se marchent pas sur les pieds, il y avait des circuits d'arbitrage pour gérer l'accès au bus. Un des deux 82385 était mis en mode maitre, l'autre était en mode esclave. Le 82385 maitre pouvait prendre le contrôle du bus, le 82385 esclave devait demander l'autorisation au premier pour accéder au bus système. Le 82385 gérait une forme limitée de cohérence des caches par invalidation. Dès que le 82385 détectait une prise de contrôle du bus par autre chose que le processeur, il surveillait les adresses transmises sur le bus. En cas de succès de cache, la ligne de cache associée était invalidée. Au-delà de ca, le 82385 avait une entrée FLUSH, qui ordonnait une invalidation totale du cache. Si cette entrée est mise à 1, toutes les lignes de cache sont invalidées. Les ''tags'' sont marqués comme invalides, mais les lignes de cache elles-mêmes ne sont pas touchées. ===L'exemple des processeurs Intel de microarchitecture ''Broadwell''=== Un autre exemple est celui du cache L4 des processeurs Broadwell et de quelques processeurs séparés. Ces processeurs ont une organisation en ''chiplet'' où le processeur incorpore plusieurs puces séparées : une puce pour le processeur proprement dit, une puce nommée ''Crystal Well'' pour le cache L4, et une puce IO pour la communication avec la RAM et la carte mère. Le processeur incorporait un cache L4 de 128 mébioctets, composé de mémoire eDRAM, qui était dispersé entre ''Crystal Well'' et les autres puces. Les données du cache L4 étaient dans ''Crystal Well'', alors que les Tags étaient soit dans le processeur lui-même, soit dans la puce IO ! La puce ''Crystal Well'' était une mémoire DRAM adressable tout ce qu'il y a de plus basique, avec cependant quelques optimisations notables. Par exemple, elle avait deux bus séparés pour l'écriture et la lecture. De plus, elle avait une organisation interne avec 128 banques, contre moins d'une dizaine pour la DDR de l'époque et environ 32 banques pour la DDR5 moderne. Elle contenait aussi quelques circuits pour gérer son rôle de mémoire cache, mais rien en ce qui concerne la gestion des tags eux-mêmes. Sur les processeurs de microarchitecture ''Broadwell'', les tags étaient placés dans le CPU et précisément dans le cache L3. À chaque accès mémoire au cache L3, les tags du cache L4 étaient consultés en parallèle. De fait, l'accès au cache L4 était assez rapide, malgré le fait que les données étaient dans une puce à part. Ajoutons à cela que le processeur et ''Crystal Well'' n'avaient pas la même finesse de gravure ni la même technologie de fabrication. Les tags étaient implémentés avec de la SRAM contre la DRAM pour les données, ce qui fait que la consultation des tags était plus rapide que l'accès aux données. Par la suite, dans certains CPU de microarchitecture ''skylake'', les tags ont été déplacés en-dehors du processeur pour finir dans le contrôleur mémoire. En faisant cela, le cache L4 pouvait être utilisé par autre chose que le processeur, et notamment par la carte graphique intégrée au CPU. Avec ''broadwell'', le fait que les tags étaient consultés en cas d'accès au L3 empêchait au GPU intégré de consulter le cache L4. Mais en déplaçant les tags dans le contrôleur mémoire, ce n'est plus le cas vu que la carte graphique a aussi accès au bus mémoire. Par contre, le temps d'accès augmente comparé à la solution précédente. On n'accède pas aux tags du L4 en parallèle du L3 : à la place, il faut consulter les tags du L3, détecter un défaut de cache L3, et ensuite accèder aux tags. ===Les caches RAM-configurables=== Un autre avantage des caches splittés est qu'on peut les modifier pour servir à la fois de mémoire cache, mais aussi de ''local store'', de mémoire RAM de petite taille. Le fonctionnement est assez simple à comprendre. Lors d'un accès au cache, on accède aux tags, puis à la RAM interne au cache. Lors d'un accès au ''local store'', on contourne l'accès au tags et on accède à la RAM interne au cache directement. Il s'agit de la technique du '''cache RAM-configurable''. L'usage de cache RAM-configurable est fréquent sur les cartes graphiques récentes, qui incorporent un ou plusieurs processeurs multicoeurs, dont le cache L1 de données est un cache RAM-configurable. [[File:Hydride cache - local store.png|centre|vignette|upright=2.0|Hydride cache - local store]] ===La compression de cache=== Une autre optimisation permise par les ''phased caches'' est l'implémentation de techniques de '''compression de cache''', qui visent à compresser des lignes de cache. L'intérêt est qu'on peut stocker plus de données dans le cache, à capacité égale. L'inconvénient est qu'on doit compresser/décompresser les lignes de cache, ce qui demande un circuit en plus et allonge les temps d'accès. En effet, le temps mis pour compresser/décompresser une ligne de cache s'ajoute au temps d'accès. Aussi, la compression de cache sert surtout pour les caches de bas niveau dans la hiérarchie mémoire, les gros caches aux temps d'accès assez longs. Une première technique, assez simple à implémenter et peu couteuse en circuit, est celle de la '''compression des lignes de cache nulles'''. Elle compresse uniquement les lignes de cache qui ne contiennent que des zéros. L'idée est qu'on ajoute, dans la mémoire des tags, un bit de contrôle pour chaque ligne de cache appelé le bit ''null''. Il indique si la ligne de cache ne contient que des zéros. Quand on lit une ligne de cache, la mémoire des tags est accédée et on vérifie le bit ''null'' : s'il vaut 1, on n'accède pas à la mémoire cache de données et un multiplexeur envoie un zéro sur le port de lecture. Le bit ''null'' est fixé lors de l'écriture d'une ligne de cache : elle passe dans un comparateur avec zéro relié à la mémoire des tags. La comparaison avec zéro peut se faire en parallèle de l'écriture ou avant (dans ce cas, on n'écrit pas la ligne de cache dans le cache). Les autres techniques de compression de cache permettent de compresser autre chose que des lignes de cache nulles. L'idée est qu'une ligne de cache physique peut par moment mémoriser plusieurs lignes de caches compressées. Par exemple, prenons un cache dont les lignes de cache font 64 octets. Il est possible de compresser deux lignes de cache pour qu'elles fassent chacune 32 octets, et les stocker dans une seule ligne de cache. Les deux lignes de cache auront des tags différents, mais pointeront sur la même ligne de cache physique. Et cela demande d'utiliser un ''phased cache'' dont la mémoire pour les tags est plus grande que la mémoire pour les données. Il n'y a donc plus une bijection entre tags et ligne de cache, mais une relation surjective. Chose qui n'est possible qu'avec un ''phased cache''. De plus, des bits de contrôles associés à chaque ''tag'' indiquent où se trouvent les lignes de cache compressées dans la ligne de cache : est-ce que c'est les 32 octets de poids fort ou de poids faible ? [[File:Compression de cache.png|centre|vignette|upright=2|Compression de cache]] Il ne semble pas que les techniques de compression de cache soient implémentées sur les processeurs modernes. Aucun n'utilise de compression de cache, à ma connaissance. Il faut dire que les techniques connues sont de mauvais compromis : le temps d'accès du cache augmente beaucoup, le cout en circuit pourrait être utilisé pour un cache non-compressé mais plus grand. Et notons que la compression de cache ne marche que si les données peuvent se compresser. Si ce n'est pas le cas, une partie de la mémoire des tags est inutilisée. Une revue de la littérature académique sur la compression de cache est disponible via ce lien, pour les curieux : * [https://inria.hal.science/hal-03285041 Understanding Cache Compression, par Carvalho et Seznec]. ==L'adressage physique ou logique des caches== Le cache utilise les adresses à lire/écrire pour déterminer s'il a une copie de la donnée en son sein. Mais l’interaction entre caches et mémoire virtuelle donne lieu à un petit problème : l'adresse utilisée est-elle une adresse virtuelle/logique ou physique ? La réponse varie suivant le processeur : certains caches utilisent l'adresse virtuelle, tandis que d'autres prennent l'adresse physique. On parle de cache '''virtuellement tagué''' dans le premier cas et de cache '''physiquement tagué''' dans le second. {| |[[File:Cache tagué virtuellement.png|vignette|Cache tagué virtuellement.]] |[[File:Cache tagué physiquement.png|vignette|Cache tagué physiquement.]] |} ===L'accès à un cache physiquement/virtuellement tagué=== La manière d'accéder à un cache dépend de s'il est virtuellement ou physiquement tagué. Il faut utiliser l'adresse virtuelle pour les premiers, physique pour les seconds. Avec un cache virtuellement tagué, l'adresse logique peut être envoyée directement au cache. La MMU ne traduit les adresses que s'il faut accéder à la mémoire RAM. Ces caches sont donc plus rapides. Avec un cache physiquement tagué, le processeur doit traduire l'adresse logique en adresse physique dans la MMU, avant d'accéder au cache. La traduction d'adresse se fait soit en accédant à une table des pages en mémoire RAM, soit en accédant à un cache spécifiquement dédié à accélérer la traduction d'adresse, la TLB (''Translation Lookaside Buffer''). Dans la quasi-totalité des cas, la traduction d'adresse passe par la TLB, ce qui fait qu'elle est raisonnablement rapide. Toujours est-il que chaque accès au cache demande d'accéder à la TLB et de faire la traduction d'adresse avant d'accéder au cache. L'accès est donc plus lent que sur les caches virtuellement tagués, où les accès sont plus directs. [[File:Virtual and Physical addressing.svg|centre|vignette|upright=2|Cache tagué virtuellement versus physiquement tagué.]] ===Les défauts des caches virtuellement tagués=== Les caches physiquement tagués sont moins rapides que les caches virtuellement adressés. Pourtant, les caches virtuellement tagués sont peu fréquents sur les processeurs modernes. Et la raison est assez intéressante : c'est une question d'adresses homonymes et synonymes. ====Les droits d'accès doivent être vérifiés lors d'un accès au cache==== Un premier problème est que la protection mémoire est compliquée avec de tels caches. Rappelons que certaines portions de mémoire sont accessibles seulement en lecture, ou sont interdites en écriture, sont inexécutables, etc. Ces droits d'accès sont gérés par la MMU, qui vérifie pour chaque accès mémoire que l'accès est autorisé. En bypassant la MMU, l'accès au cache virtuellement tagué ne permet pas de faire ces vérifications. Il est possible de charger une donnée en lecture seule dans le cache, mais d'y faire des accès en écriture pour les accès ultérieurs. Les solutions à cela sont multiples. La première consiste à consulter la MMU en parallèle de l'accès au cache. L'accès au cache est alors réalisé de manière spéculative, et est ensuite confirmé/annulé une fois que la MMU a rendu son verdict. Les performances du cache restent alors les mêmes : l'accès à la MMU se fait en parallèle de l'accès au cache, pas avant. Une autre solution est d'ajouter les droits d'accès en question dans la ligne de cache, dans les bits de contrôle situés après le Tag. Chaque accès au cache récupère ces bits de contrôle et vérifie si l'accès est autorisé. L'inconvénient est que les lignes de cache deviennent plus longues, les droits d'accès sont dupliqués entre MMU et cache. Mais si le budget en transistor suit, ce n'est rien d'insurmontable. ====Les adresses homonymes perturbent la gestion du cache==== Pour rappel, une adresse logique homonyme correspond à plusieurs adresses physiques différentes. Elles surviennent quand chaque programme a son propre espace d'adressage. Dans ce cas, une adresse logique correspondra à une adresse physique différente par programme.Une autre manière de voir les choses est qu'il y a en réalité deux adresses homonymes, qui ont la même valeur, mais appartiennent à des espaces d'adressage différentes. Et c'est cette seconde interprétation que nous allons utiliser. Les caches doivent gérer ces adresses homonymes et faire en sorte que la lecture/écriture d'une adresse homonyme se fasse à la bonne adresse physique, dans la bonne ligne de cache. Et autant un cache physiquement tagué n'a aucun problème avec ça, vu qu'il ne gère que des adresses physiques, autant des problèmes surviennent avec les caches virtuellement tagués. Le problème est que les caches virtuellement tagués doivent faire la différence entre deux adresses homonymes de même valeur. Pour corriger ces problèmes, il existe deux grandes méthodes. La première méthode est simple : '''vider les caches''' en changeant de programme. Leur contenu est rapatrié en mémoire RAM, puis les caches sont remis à zéro. Le vidage du cache recopie les lignes de cache ''dirty'' (modifiées) en RAM, puis efface/invalide tout le cache. C'est à cela que servent les instructions ''clean'' et d'invalidation vues plus haut, elles ont été inventées pour cette situation précise. Lorsque le système d'exploitation déclenche une commutation de contexte, à savoir qu'il change le programme en cours d'exécution, le processeur vide tous les caches du processeur. Les interruptions font la même chose, elles vide tous les caches du processeur. Une seconde méthode numérote chaque programme en cours d'exécution, chaque processus. Le numéro attribué est spécifique à chaque processus, ce qui fait qu'il est appelé un '''identifiant de processus CPU'''. Le processeur mémorise l'identifiant du programme en cours d'exécution dans un registre dédié. L'identifiant de processus CPU est utilisé lors des accès mémoire. Chaque ligne de cache contient le numéro de l'espace d'adressage associé, dans son ''tag''. Lors de chaque accès mémoire, l'ID du registre est comparé à l'ID de la ligne de cache accédée, pour vérifier que l'accès mémoire accède à la bonne donnée. Cette méthode n'est pas très économe en termes de transistors. L'usage d'identifiant de processus CPU est clairement meilleure en termes de performance, les commutations de contexte sont plus rapides. Par contre, le budget en transistor est plus important. Un autre défaut de cette méthode est que l'identifiant de processus est généralement codé sur une dizaine de bits, alors que le système d'exploitation utilise des identifiants de processus beaucoup plus larges, de 32 à 64 bits sur les CPU 32/64 bits. L'OS doit gérer la correspondance entre identifiants de processus CPU et ceux de l'OS. Parfois, pour cette raison, les OS n'utilisent pas toujours ce système d'identifiant de processus CPU. ====Les adresses synonymes perturbent aussi la gestion du cache==== La gestion des adresses synonymes est aussi un gros problème sur les caches virtuellement tagués. Pour rappel, il s'agit du cas où des adresses logiques différentes pointent vers la même adresse physique. Typiquement, quand deux programmes se partagent un morceau de mémoire, ce morceau correspondra à des adresses synonymes dans les deux espaces d'adressage. Mais il arrive que l'on ait des adresses synonymes dans le même espace d'adressage, ce n'est pas si rare ! Autant les adresses synonymes ne posent aucun problème avec les caches physiquement tagués, ce n'est pas le cas avec les caches virtuellement adressés. Sur ces caches, deux adresses logiques synonymes vont tomber dans deux lignes de cache différentes. Corriger ce problème demande d'ajouter des circuits annexes pour détecter les adresses synonymes, qui sont vraiment complexes et ont un cout en termes de performance. Aussi, les caches virtuellement tagués sont très peu utilisés sur les processeurs modernes. ===Les caches virtuellement adressés, mais physiquement tagués=== Si les caches physiquement et virtuellement tagués ont des défauts, il existe un intermédiaire qui est un bon compromis entre ces deux extrêmes. Il s'agit des '''caches virtuellement adressés - physiquement tagués''', aussi appelés '''caches pseudo-virtuels'''. Pour comprendre comment ils fonctionnent, précisons que ces caches sont soit des caches ''direct-mapped'', soit des caches associatifs par voie (composés de plusieurs RAM ''direct-mapped'' accédées en parallèle, plusieurs voies). L'accès à ce genre de cache se fait en deux temps : on accède à un ou plusieurs RAM ''direct-mapped'' et on vérifie ensuite les ''Tags'' pour sélectionner la bonne voie. Sur les caches ''direct-mapped'', on n'a qu'une seule RAM ''direct-mapped''. Sur les caches associatifs, on a plusieurs RAM ''direct-mapped'', appelées des voies, qui sont accédées en parallèle. L'accès se fait donc en deux étapes : adresser les RAM ''direct-mapped'' avec un indice, vérifier les ''tags'' avec le reste de l'adresse. Une autre chose à rappeler est que l'adresse logique est composée de deux parties : un numéro de page logique qui indique dans quel page se situe l'adresse, un décalage/''offset'' qui indique la position de l'adresse dans la page. La traduction d'adresse transforme le numéro de page logique en numéro de page physique, mais laisse le décalage intouché. L'idée est d'utiliser le décalage pour adresser les RAM avec le décalage, tandis que le numéro de page sert de ''tag''. Le décalage est découpé en deux lors de l'accès au cache : les bits de poids fort forment l'indice (l'adresse envoyée à la voie), les bits de poids faible donnent la position de l'adresse dans la ligne de cache. L'idée est d'utiliser un numéro de page physique pour les ''tags'', mais d'adresser les voies avec le décalage logique. Les deux servent à des instants différents : vérification des ''tags'' pour l'adresse physique, accès aux voies pour l'adresse logique. Ainsi, le problème des adresses synonymes ou homonymes est résolu par l'utilisation de l'adresse physique pour les tags. Par contre, l'accès au cache est plus rapide, car on utilise l'adresse logique pour la première étape. Le processeur accède à la TLB et récupère l'adresse physique pendant que l'on adresse les voies, les deux sont faits en parallèle, ce qui fait que tout se passe comme si l'accès à la TLB était gratuit. La TLB étant assez rapide comparé au cache, l'adresse physique est disponible quand on doit faire la comparaison avec les ''tags''. [[File:Virtual - Physical - Pseudo Virtual addressing.svg|centre|vignette|upright=2|Adressage pseudo virtuel des caches.]] Il s'agit d'un excellent compromis entre performance et correction des problèmes des adresses synonymes/homonymes. Tous les caches des processeurs haute performance utilisent cette méthode, au moins pour leurs caches L1. Les caches L2 tendent à utiliser des caches physiquement adressés, pour lesquels la latence d'accès est suffisante pour qu'on accède à la TLB en amont. La raison est assez simple à expliquer, elle provient d'une contrainte assez précise sur le calcul de l'indice. La conséquence est qu'un cache ''direct-mapped'' ne peut pas dépasser la taille d'une page, soit 4 kibioctets sur les ordinateurs actuels. Sur les caches associatifs, on peut dépasser cette limite en augmentant le nombre de voies, mais la taille maximale d'une voie reste celle d'une page. Cette contrainte n'est pas trop grave sur les caches de petite taille, dont les caches L1. La plupart d'entre eux ont trouvé un compromis idéal avec moins d'une dizaine de voies par cache, chacun de 4 kibioctets, ce qui donne des caches allant de 16 à 64 kibioctets, soit entre 4 et 16 voies. Par contre, un cache de grande taille doit utiliser un grand nombre de voies, ce qui est peu pratique. Aussi, cette technique de caches pseudo-virtuels n'est pas toujours appliquée sur les caches L2, qui sont physiquement adressés. Il faut dire qu'on accède au cache L2 lors d'un défaut dans le cache L1, et l'adresse physique est disponible à ce moment-là, elle a déjà été récupérée lors de l'accès au cache L1. On peut donc l'utiliser pour adresser le cache L2 sans perte de performance. ==Le remplacement des lignes de cache== Lorsqu'un cache est rempli et qu'on charge une nouvelle donnée dedans, il faut faire de la place pour cette dernière. Dans le cas d'un cache directement adressé, il n'y a rien à faire vu que la ligne de cache à évincer est déterminée lors de la conception du cache. Mais pour les autres caches, la donnée peut aller dans n'importe quelle ligne ou voie. Or, le choix des données à rapatrier en RAM doit être le plus judicieux possible : on doit virer de préférence des données inutiles. Rapatrier une donnée qui sera surement utilisée sous peu est inutile, et il vaudrait mieux supprimer des données qui ne serviront plus ou alors dans longtemps. Il existe différents algorithmes spécialement dédiés à résoudre ce problème efficacement, directement câblés dans les unités de gestion du cache. Certains sont vraiment très complexes, aussi je vais vous présenter quelques algorithmes particulièrement simples. Mais avant de voir ces algorithmes, il faut absolument que je vous parle d'une chose très importante. Quel que soit l'algorithme en question, il choisit la ligne de cache à évincer et recopie son contenu dans la RAM. Ce qui demande d'identifier et de sélectionner une ligne de cache parmi toutes les autres. Pour cela, le circuit de remplacement attribue une adresse chaque ligne de cache ! Vous avez bien vu : chaque ligne de cache est numérotée par une adresse, interne au cache. ===Le remplacement aléatoire=== Premier algorithme : la donnée effacée du cache est choisie au hasard ! C'est contre-intuitif, mais cet algorithme donne des résultats assez honorables, en plus d'utiliser très peu de portes logiques (un générateur de nombres pseudo-aléatoire est un circuit assez simple). Généralement, les défauts de cache sont séparés par un nombre assez important et irrégulier de cycles d'horloge. Dans ces conditions, cette technique donne un bon résultat. ===FIFO : first in, first out=== Avec l'algorithme FIFO, la donnée effacée du cache est la plus ancienne, celle chargée dans le cache avant les autres. Cet algorithme est très simple à implémenter en circuit, concevoir une mémoire de type FIFO n'étant pas très compliqué, comme on l’a vu dans le chapitre dédié à ce type de mémoires. Et on peut dire que dans le cas d'un cache, l'implémentation est encore plus simple et se contente d'un seul registre/compteur. Typiquement, il suffit d'ajouter un registre qui mémorise où se situe la donnée la plus récente. Toute insertion d'une nouvelle donnée se fait à l'adresse suivante, ce qui demande juste d'incrémenter le registre avant d'utiliser son contenu pour l'accès mémoire. [[File:Algorithme FIFO de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme FIFO de remplacement des lignes de cache.]] Cet algorithme possède une petite particularité sur les caches associatifs par voie : en augmentant le nombre d'ensembles, les performances peuvent se dégrader : c'est ce qu'on appelle l''''anomalie de Bélády'''. ===MRU : most recently used=== Avec l'algorithme MRU, la donnée remplacée est celle qui a été utilisée le plus récemment. Cet algorithme s'implémente simplement avec un registre, dans lequel on place le numéro de la dernière ligne de cache utilisée. Cet algorithme de remplacement est très utile quand un programme traverse des tableaux du premier élément jusqu'au dernier : les données du tableau sont rarement réutilisées, rendant le cache inutile. Il est prouvé que dans ces conditions, l'algorithme MRU est optimal. Mais dans toutes les autres conditions, cet algorithme a des performances assez misérables. ===LFU : least frequently used=== Avec l'algorithme LFU, la donnée supprimée est celle qui est utilisée le moins fréquemment. Cet algorithme s'implémente en associant un compteur à chaque ligne de cache, qui est incrémenté à chaque accès mémoire. La ligne la moins récemment utilisée est celle dont le compteur associé a la plus petite valeur. Implémenter cet algorithme prend pas mal de transistors, car il faut rajouter autant de compteurs qu'il y a de lignes de cache, en plus d'un circuit pour comparer les compteurs et d'un encodeur. [[File:Algorithme LFU de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme LFU de remplacement des lignes de cache]] ===LRU : least recently used=== Avec l'algorithme LRU, la donnée remplacée est celle qui a été utilisée le moins récemment. Cet algorithme se base sur le principe de localité temporelle, qui stipule qu'une donnée accédée récemment a de fortes chances d'être réutilisée dans un futur proche. Et inversement, la donnée la moins récemment utilisée du cache est celle qui a le plus de chance de ne servir à rien dans le futur. Autant la supprimer en priorité pour faire de la place à des données potentiellement utiles. Implémenter l'algorithme LRU peut se faire de différentes manières, qui ont pour point commun d'enregistrer les accès au cache pour en déduire la ligne la moins récemment accédée. La manière la plus simple demande d'utiliser un compteur pour chaque ligne de mémoire cache, un peu comme le LFU. La différence avec le LFU est que le compteur n'est pas incrémenté lors d'un accès mémoire. À la place, ce compteur est incrémenté régulièrement, chaque incrémentation ayant lieu en même temps pour tous les compteurs. Quand un bloc est chargé dans le cache, ce compteur est mis à zéro. Quand une ligne de cache doit être remplacée, un circuit va vérifier la valeur de tous les compteurs : la ligne LRU (la moins récemment utilisée), est celle dont le compteur a la valeur la plus haute. Le circuit est composé d'un paquet de comparateurs, et d'un encodeur, comme pour l'agorithme LFU. ===Les approximations du LRU=== Implémenter le LRU demande un nombre de transistors proportionnel au carré du nombre de lignes de cache. Autant dire que le LRU devient impraticable sur de gros caches. Ce qui fait que les processeurs modernes implémentent des variantes du LRU, moins couteuses en transistors, qui donnent un résultat approximativement semblable au LRU. En clair, ils ne sélectionnent pas toujours la ligne de cache la moins récemment utilisée, mais une ligne de cache parmi les moins récemment utilisées. Ce n'est pas un problème si grave que cela car les lignes les moins récemment utilisées ont toutes assez peu de chance d'être utilisées dans le futur. Entre choisir de remplacer une ligne qui a 0,5 % de chances d'être utilisée dans le futur et une autre qui a une chance de seulement 1 %, la différence est négligeable en termes de taux de succès. Mais les gains en termes de circuits ou de temps d'accès au cache de ces algorithmes sont très intéressants. L'algorithme le plus simple consiste à couper le cache (ou chaque voie s'il est associatif) en plusieurs sections. L'algorithme détermine la section la moins récemment utilisée, avant de choisir aléatoirement une ligne de cache dans cette section. Pour implémenter cet algorithme, il nous suffit d'un registre qui mémorise le morceau le moins récemment utilisé, et d'un circuit qui choisit aléatoirement une ligne de cache. Cette technique s'adapte particulièrement bien avec des caches associatifs à voies : il suffit d'utiliser autant de morceaux que de voies. Autre algorithme, un peu plus efficace : le '''pseudo-LRU de type M'''. Cet algorithme attribue un bit à chaque ligne de cache, bit qui sert à indiquer de façon approximative si la ligne de cache associée est une candidate pour un remplacement ou non. Il vaut 1 si la ligne n'est pas une candidate pour un remplacement et zéro sinon. Le bit est mis à 1 lorsque la ligne de cache associée est lue ou écrite. Évidemment, au fil du temps, toutes les lignes du cache finiront par avoir leur bit à 1. Lorsque cela arrive, l'algorithme remet tous les bits à zéro, sauf pour la dernière ligne de cache accédée. L'idée derrière cet algorithme est d'encercler la ligne de cache la moins récemment utilisée au fur et à mesure des accès. L'encerclement commence lorsque l'on remet tous les bits associés aux lignes de cache à 0, sauf pour la ligne accédée en dernier. Au fur et à mesure des accès, l'étau se resserre autour de la ligne de cache la moins récemment utilisée. Après un nombre suffisant d'accès, l'algorithme donne une estimation particulièrement fiable. Et comme les remplacements de lignes de cache sont rares comparés aux accès aux lignes, cet algorithme finit par donner une bonne estimation avant qu'on ait besoin d'effectuer un remplacement. Le dernier algorithme d'approximation, le '''PLURt''', se base sur ce qu'on appelle un arbre de décision. Il a besoin de n − 1 bits pour déterminer la ligne LRU. Ces bits doivent être organisés en arbre, comme illustré plus bas. Chacun de ces bits sert à dire : le LRU est à ma droite ou à ma gauche : il est à gauche si je vaux 0, et à droite si je vaux 1. Trouver le LRU se fait en traversant cet arbre, et en interprétant les bits un par un. Au fur et à mesure des lectures, les bits sont mis à jour dans cet arbre, et pointent plus ou moins bien sur le LRU. La mise à jour des bits s'effectue lors des lectures et écritures : quand une ligne est lue ou écrite, elle n'est pas la ligne LRU. Pour l'indiquer, les bits à 1 qui pointent vers la ligne de cache sont mis à 0 lors de la lecture ou écriture. {| |[[File:Organisation des bits avec l'algorithme PLURt.jpg|vignette|Organisation des bits avec l'algorithme PLURt.]] |[[File:Ligne de cache pointée par les bits de l'algorithme.png|vignette|Ligne de cache pointée par les bits de l'algorithme.]] |} ===LRU amélioré=== L'algorithme LRU, ainsi que ses variantes approximatives, sont très efficaces tant que le programme respecte relativement bien la localité temporelle. Par contre, Le LRU se comporte assez mal dans les circonstances ou la localité temporelle est mauvaise mais où la localité spatiale est respectée, le cas le plus emblématique étant le parcours d'un tableau. Pour résoudre ce problème, des variantes du LRU existent. Une variante très connue, l''''algorithme 2Q''', utilise deux caches : un cache FIFO pour les données accédées une seule fois et un second cache LRU. Évidemment, les données lues une seconde fois sont migrées du cache FIFO vers le cache LRU, ce qui n'est pas très pratique. Les processeurs n'utilisent donc pas cette technique, mais celle-ci est utilisée dans les caches de disque dur. D'autres variantes du LRU combinent plusieurs algorithmes à la fois et vont choisir lequel de ces algorithmes est le plus adapté à la situation. Notre cache pourra ainsi détecter s’il vaut mieux utiliser du MRU, du LRU, ou du LFU suivant la situation. ==Les écritures dans le cache : gestion et optimisations== Les écritures se font à une adresse mémoire bien précise, qui peut ou non être chargée dans le cache. Si la donnée à écrire est chargée dans le cache, elle est modifiée directement dans le cache, mais elle ne l'est pas forcément en mémoire RAM. Suivant le processeur, les écritures sont ou non propagées en mémoire RAM. Il existe deux stratégies d'écritures, appelées respectivement le ''write-back'' et le ''write-through''. Avec un cache ''write-back'', si la donnée à mettre à jour est présente dans le cache, on écrit dans celui-ci sans écrire dans la mémoire RAM. Dans ces conditions, une donnée n'est enregistrée en mémoire que si celle-ci quitte le cache, ce qui évite de nombreuses écritures mémoires inutiles. [[File:Cache write-through.png|centre|vignette|upright=2|Cache write-through.]] Avec les caches '''Write-Through''', toute écriture dans le cache est propagée en RAM. Cette stratégie augmente le nombre d'écritures dans la mémoire RAM, ce qui peut saturer le bus reliant le processeur à la mémoire. Les performances de ces caches sont donc légèrement moins bonnes que pour les caches ''write back''. Par contre, ils sont utiles dans les architectures avec plusieurs processeurs, comme nous le verrons dans les chapitres sur les architectures multiprocesseurs. [[File:Cache write-back.png|centre|vignette|upright=2|Cache write-back.]] ===Les caches ''Write-through''=== Sans optimisation particulière, on ne peut écrire dans un cache ''write-through'' pendant qu'une écriture en RAM a lieu en même temps : cela forcerait à effectuer deux écritures simultanées, en comptant celle imposée par l'écriture dans le cache. Pour éviter cela, certains caches ''write-through'' intègrent un '''tampon d’écriture''', qui sert de file d'attente pour les écritures en RAM. C'est une mémoire FIFO dans laquelle on place temporairement les données à écrire en RAM, où elles attendent en attendant que la RAM soit libre. Grâce à lui, le processeur peut écrire dans un cache même si d'autres écritures sont en attente dans le tampon d'écriture. Par souci d'efficacité, des écritures à la même adresse en attente dans le tampon d’écriture sont fusionnées en une seule. Cela fait un peu de place dans le tampon d’écriture, et lui permet d'accumuler plus d'écritures avant de devoir bloquer le cache. Il est aussi possible de fusionner des écritures à adresses consécutives de la mémoire en une seule écriture en rafales. Dans les deux cas, on parle de '''combinaison d'écriture'''. Mais la technique du tampon d'écriture a cependant un léger défaut qui se manifeste dans une situation bien précise : quand le processeur veut lire une donnée en attente dans le tampon d’écriture. La première manière de gérer cette situation est de mettre en attente la lecture tant que la donnée n'a pas été écrite en mémoire RAM. On peut aussi lire la donnée directement dans le tampon d'écriture, cette optimisation portant le nom de '''''store-to-load forwading'''''. Dans tous les cas, il faut détecter le cas où une lecture accède à une donnée dans le tampon d'écriture. À chaque lecture, l'adresse à lire est envoyée au tampon d'écriture, qui vérifie si une écriture en attente se fait à cette adresse. Pour cela, le tampon d’écriture doit être un cache, dont chaque entrée mémorise une écriture. Chaque ligne de cache contient la donnée à écrire, et le tag de la ligne de cache contient l'adresse où écrire la donnée. Notons que cache d'écriture a une politique de remplacement de type FIFO, le tampon d'écriture non-optimisé étant une mémoire FIFO. ===Les caches ''Write-back''=== Les caches ''write-back'' ont beau avoir des performances supérieures à celles des caches ''write-through'', il existe des optimisations qui permettent d'améliorer leurs performances. Ces optimisations consistent à ajouter des caches spécialisés à côté du cache proprement dit. Ces caches permettent de mémoriser des données qui sont éliminées du cache par les algorithmes de remplacement de ligne cache, sans pour autant faire une écriture en RAM. En suivant la procédure habituelle de remplacement des lignes de cache, on doit rapatrier la ligne en RAM avant d'en charger une nouvelle. On peut améliorer la situation en faisant l'inverse : on charge la nouvelle ligne pendant que l'ancienne donnée est rapatriée en RAM. Ainsi, la nouvelle ligne est disponible plus tôt pour le processeur, diminuant son temps d'attente. Pour implémenter cette technique, on doit mémoriser l'ancienne ligne de cache temporairement dans un '''cache d’éviction''' (ou ''write-back buffer''). [[File:Cache d’éviction.png|centre|vignette|upright=2|Cache d’éviction]] Les caches directement adressés ou associatifs par voie possèdent aussi un tampon d’écriture amélioré. Pour limiter les défauts par conflit de ces caches, des scientifiques ont eu l'idée d'insérer un cache pour stocker les données virées du cache. En faisant ainsi, si une donnée est virée du cache, on peut alors la retrouver dans ce cache spécialisé. Ce cache s'appelle le '''cache de victime'''. Ce cache de victime est géré par un algorithme de suppression des lignes de cache de type FIFO. Petit détail : ce cache utilise un tag légèrement plus long que celui du cache directement adressé au-dessus de lui. L'index de la ligne de cache doit en effet être contenu dans le tag du cache de victime, pour bien distinguer deux adresses différentes, qui iraient dans la même ligne du cache juste au-dessus. [[File:Victim Cache Implementation Example.svg|centre|vignette|upright=1|Cache de victime.]] ===La configuration du fonctionnement du cache=== Sur de nombreux processeurs, il est possible de configurer la mémoire cache pour qu'elle fonctionne soit en mode ''write-back'', soit en mode ''write-through''. Pour cela, les processeurs modernes incorporent des '''registres de configuration du cache'''. Le terme ''registre de configuration du cache'' est assez transparent et indique bien quel est leur rôle. Ils configurent comment le cache est utilisé et permettent notamment de configurer le cache pour dire s'il doit fonctionner en mode ''write-back'' ou ''write-through''. Ils permettent aussi d'activer ou de désactiver la combinaison sur écriture. Les registres en question sont configurés soit par le BIOS, soit par le système d'exploitation. Ce sont des registres protégés, que les applications ne peuvent pas configurer, elles n'en ont pas le droit. Typiquement, ils ne sont accessibles en écriture qu'en mode noyau. Sur les processeurs x86, les registres de configuration du cache sont appelés des '''''Memory type range registers''''' (''MTRRs''). Les MTRRs sont assez nombreux, et il y a notamment une différence entre mode réel et protégé. Si vous vous souvenez des chapitres sur le mode d'adressage et la mémoire virtuelle, vous vous souvenez que les processeurs x86 incorporent plusieurs modes de fonctionnement. En mode réel, le processeur ne peut adresser qu'un mébioctet de RAM, avec un système de segmentation particulier. En mode protégé, le processeur peut adresser toute la mémoire et la segmentation fonctionne différemment, quand elle n'est pas simplement désactivée. Les MTRRs sont séparés en deux : ceux pour le mode réel, ceux pour le mode protégé. Les MTRRs fixes sont ceux qui configurent le cache en mode réel, ils étaient utilisés pour gérer l'accès au BIOS, à la mémoire VGA de la carte graphique, et quelques autres accès aux entrées-sorties basiques gérées nativement par le BIOS. Pour le mode protégé, les processeurs au-delà du 386 incorporent des MTRRs variables, qui servent pour les autres entrées-sorties en général, notamment les périphériques PCI, la mémoire vidéo de la carte graphique, et j'en passe. De nos jours, les registres de configuration du cache sont désuets et cette fonctionnalité est gérée directement par la mémoire virtuelle. La table des pages contient, pour chaque page mémoire, des bits de contrôle qui disent si la page mémoire est cacheable ou non. Le contournement de cache est alors géré par le système de mémoire virtuelle, le cache de TLB et tout ce qui va avec. ===L’allocation sur écriture=== Que faire quand une écriture modifie une donnée qui n'est pas dans le cache ? Doit-on écrire la donnée dans le cache, ou non ? Si la donnée est écrite dans le cache, on dit que le cache fait une '''allocation sur l'écriture''' (ou ''write-allocate''). Certains caches effectuent une telle allocation sur écriture, mais d'autres ne le font pas ou du moins pas systématiquement. L’allocation sur écriture peut se décliner en deux sous-catégories : le '''chargement à la demande''' et l''''écriture immédiate'''. Dans le premier cas, on charge la donnée à modifier dans le cache, et on la remplace avec la donnée écrite. Dans l'écriture immédiate, l'écriture a lieu directement dans le cache et la donnée à modifier n'est pas chargée dans le cache. Évidemment, seule une portion de la ligne de cache contient la donnée écrite (valide), et le reste contient des données invalides. Le cache doit savoir quelles sont les portions du cache qui sont valides : cela demande d'utiliser un ''sector cache''. [[File:Write-back with write-allocation.svg|centre|vignette|upright=2|Cache Write-back avec allocation sur écriture.]] Sans allocation sur écriture, l'écriture est transférée directement aux niveaux de cache inférieurs ou à la mémoire si la donnée à modifier n'est pas dans le cache. Certains caches de ce genre utilisent une petite optimisation : lors de toute écriture, ils supposent que l'écriture donnera un succès de cache. Si c'est le cas, la ligne de cache qui contient la donnée est mise à jour avec la donnée à écrire. Mais si ce n'est pas le cas, la ligne de cache est invalidée, et l'écriture est transférée directement à la mémoire ou aux niveaux de cache inférieurs. [[File:Write-through with no-write-allocation.svg|centre|vignette|upright=2|Cache Write-through sans allocation sur écriture.]] ===La cohérence des caches=== Il arrive parfois que la mémoire d'un ordinateur soit mise à jour, sans que les modifications soient répercutées dans les mémoires cache. Dans ce cas, le cache contient une donnée périmée. Or, un processeur doit toujours éviter de se retrouver avec une donnée périmée et doit toujours avoir la valeur correcte dans ses caches : cela s'appelle la '''cohérence des caches'''. Il est possible de se retrouver avec des valeurs périmées dans le cache sur les ordinateurs avec plusieurs processeurs, ou si un périphérique écrit en RAM, les modifications ne sont pas répercutées automatiquement dans les mémoires cache. Pour résoudre ce problème, on peut interdire de charger dans le cache des données stockées dans les zones de la mémoire dédiées aux périphériques. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires cache. Autre solution : utiliser le fait que les périphériques déclenchent une interruption matérielle pour laisser le contrôleur DMA accéder à la mémoire. Dans ce cas, il suffit de vider les caches à chaque interruption matérielle. Le processeur peut le faire automatiquement, ou fournir des instructions pour. ==Le ''cache bypassing'' : contourner le cache== Dans certaines situations, le cache n'est pas utilisé pour certains accès mémoire. Diverses techniques permettent en effet d'effectuer des accès mémoire qui contournent le cache, qui ne passent pas par le cache. Ils sont utilisés quand l'accès en cache fait que des instructions normales ne fonctionnent pas. Par exemple, de tels accès directs à la RAM sont notamment utilisés pour l'implémentation d'instructions atomiques, une classe d'instructions spécifiques utilisées sur les processeurs multicœurs, dont nous parlerons dans plusieurs chapitres. Mais ils sont aussi utilisés pour l'accès aux périphériques, ce que nous allons voir maintenant. ===Accéder aux périphériques demande de contourner le cache=== Pour rappel, un périphérique (au sens d'entrée-sortie) contient des registres d’interfaçage qui ont une adresse au même titre que les cases mémoire. Un périphérique peut à tout instant modifier ses registres d’interfaçage, ce qui se répercute automatiquement dans l'espace d'adressage, mais rien de tout cela n'est transmis au cache. Si les accès aux périphériques passaient par l'intermédiaire du cache, on aurait droit à des problèmes. On aurait encore une fois droit à des problèmes de cohérence des caches. Le problème est géré différemment suivant que l'on utilise un espace d'adressage séparé ou des entrées-sorties mappées en mémoire. La solution est que les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache. Cela demande d'adapter le cache et le processeur. L'implémentation exacte dépend de comment sont adressés les périphériques. Pour rappel, il y a deux solutions pour adresser les périphériques : soit les périphériques disposent d'un espace d'adressage séparé de celui de la mémoire, soit il y un espace d'adressage unique partagé entre processeur et mémoire. Les deux cas donnent des solutions différentes. Avec un espace d'adressage séparé, l'espace d'adressage des périphériques n'est pas caché : aucun accès dans cet espace d'adressage ne passe par le cache. La mémoire cache n'est utilisée que pour l'espace d'adressage des mémoires, rien d'autre. C'est de loin le cas le plus simple : il suffit de concevoir le processeur pour. Il dispose d'instructions séparées pour les accès aux registres d’interfaçage et à la RAM/ROM, les premières ne passent pas par le cache, les autres si. Avec des entrées-sorties mappées en mémoire, la même solution est utilisée, mais dans une version un peu différente. Là encore, les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache, si on veut qu'ils marchent comme ils le doivent. Cela demande d'adapter le cache et le matériel pour que accès aux périphériques mappés en mémoire contournent le cache. Des adresses, voire des zones entières de la mémoire, sont marquées comme étant non-cachables. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires caches. Là encore, le processeur doit être prévu pour : on doit pouvoir le configurer de manière à marquer certaines zones de la RAM comme non-cacheable. Reste qu'il faut marquer des régions de la RAM comme non-cacheable. Pour cela, on améliore les registres de configuration du cache, vus plus haut, afin qu'ils permettent de configurer certaines portions de la RAM pour préciser qu'elles ne doivent pas être mises en cache, qu'il faut activer le contournement de cache pour celles-ci. ===Contourner le cache pour des raisons de performance=== Il arrive que des données avec une faible localité soient chargées dans le cache inutilement. Or, il vaut mieux que ces données transitent directement entre le processeur et la mémoire, sans passer par l'intermédiaire du cache. Pour cela, le processeur peut fournir des instructions d'accès mémoire qui ne passent pas par le cache, à côté d'instructions normales. De telle instructions sont appelées des '''instructions mémoire non-temporelles'''. Non-temporelle, dans le sens : pas de localité temporelle (c.a.d que les données ne seront pas réutilisées plus tard). Mais il existe aussi des techniques matérielles, où le cache détecte à l'exécution les lectures qui gagnent à contourner le cache. La dernière méthode demande d'identifier les instructions à l'origine des défauts de cache, le processeur accédant directement à la RAM quand une telle instruction est détectée. Si une instruction d'accès mémoire fait trop de défauts de cache, c'est signe qu'elle gagne à contourner le cache. L'idée est de mémoriser, pour chaque instruction d'accès mémoire, un historique de ses défauts de cache. Il existe plusieurs méthodes pour cela, mais toutes demandent d'ajouter de quoi mémoriser l'historique des défauts de cache des instructions. L'historique est mémorisé dans une mémoire appelée la '''table d’historique des défauts de lecture''' (''load miss history table''), qui est souvent un cache. L'historique en question est, dans sa version la plus simple, un compteur de quelques bits incrémenté à chaque succès de cache et décrémenté à chaque défaut de cache, qui indique si l'instruction a en moyenne fait plus de défauts ou de succès de cache. La table associe le ''program counter'' d'une instruction mémoire à cet historique. À la première exécution d'une instruction d'accès mémoire, une entrée de cette table est réservée pour l'instruction. Lors des accès ultérieurs, le processeur récupérer les informations associées et décide s'il faut contourner le cache ou non. ==La hiérarchie mémoire des caches== [[File:Cache Hierarchy.png|vignette|Hiérarchie de caches]] On pourrait croire qu'un seul cache est largement suffisant pour compenser la lenteur de la mémoire. Hélas, les processeurs sont devenus tellement rapides que les caches sont eux-mêmes très lents ! Pour rappel, plus une mémoire peut contenir de données, plus elle est lente. Et les caches ne sont pas épargnés. Si on devait utiliser un seul cache, celui-ci serait très gros et donc trop lent. La situation qu'on cherche à éviter avec la mémoire RAM revient de plus belle. Même problème, même solution : si on a décidé de diviser la mémoire principale en plusieurs mémoires de taille et de vitesse différentes, on peut bien faire la même chose avec la mémoire cache. Depuis environ une vingtaine d'années, un processeur contient plusieurs caches de capacités très différentes : les caches L1, L2 et parfois un cache L3. Certains de ces caches sont petits, mais très rapides : c'est ceux auxquels on va accéder en priorité. Viennent ensuite d'autres caches, de taille variable, mais plus lents. Les processeurs ont donc une hiérarchie de caches qui se fait de plus en plus complexe avec le temps. Cette hiérarchie est composée de plusieurs niveaux de cache, qui vont des niveaux inférieurs proches de la mémoire RAM à des niveaux supérieurs proches du processeur. Plus on monte vers les niveaux supérieurs, plus les caches sont petits et rapides. Un accès mémoire dans une hiérarchie de cache fonctionne comme suit : on commence par vérifier si la donnée recherchée est dans le cache le plus rapide, à savoir le cache L1. Si c'est le cas,n on la charge depuis ce cache directement. Si elle n’y est pas, on vérifie si elle est dans le cache de niveau supérieur, le cache L2. Et rebelote ! Si elle n'y est pas, on vérifie le cache du niveau supérieur. Et on répète cette opération, jusqu’à avoir vérifié tous les caches. Si la donnée n'est dans aucun cache, on doit alors aller chercher la donnée en mémoire. [[File:Hiérarchie de caches.png|centre|vignette|upright=2|Hiérarchie de caches]] Il y a des différences assez notables entre chaque niveau de cache. Par exemple, les différents niveaux de cache n'ont pas forcément les mêmes politiques de remplacement des lignes de cache. Le cache L1 a généralement une politique de remplacement simple, très rapide, mais peu efficace. De même, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Le cache le plus proche de la mémoire est appelé le '''cache de dernier niveau''', ''Last Level Cache'' en anglais. Il a parfois des caractéristiques totalement différentes des autres caches. Par exemple, sur les processeurs multicoeurs, le cache L3 n'est techniquement pas dans le cœur, mais fait partie d'un ensemble de circuits reliés, comme le contrôleur mémoire ou l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc. ===Les caches exclusifs et inclusifs=== Notons que du point de vue de cette vérification, il faut distinguer les caches inclusifs et exclusifs. Avec les caches inclusifs, si une donnée est présente dans un cache, alors elle est présente dans les caches des niveaux inférieurs, ce qui implique l'existence de données en doublon dans plusieurs niveaux de cache. À l'opposé, les caches exclusifs font que toute donnée est présente dans un seul cache, pas les autres. Il existe aussi des caches qui ne sont ni inclusifs, ni exclusifs. Sur ces caches, chaque niveau de cache gère lui-même ses données, sans se préoccuper du contenu des autres caches. Pas besoin de mettre à jour les niveaux de cache antérieurs en cas de mise à jour de son contenu, ou en cas d'éviction d'une ligne de cache. La conception de tels caches est bien plus simple. Dans les '''caches exclusifs''', le contenu d'un cache n'est pas recopié dans le cache de niveau inférieur. Il n'y a pas de donnée en double et on utilise 100 % de la capacité du cache, ce qui améliore le taux de succès. Par contre, le temps d'accès est un peu plus long. La raison est que si une donnée n'est pas dans le cache L1, on doit vérifier l'intégralité du cache L2, puis du cache L3. De plus, assurer qu'une donnée n'est présente que dans un seul cache nécessite aux différents niveaux de caches de communiquer entre eux pour garantir que l'on a pas de copies en trop d'une ligne de cache, ce qui peut prendre du temps. [[File:Caches exclusifs.png|centre|vignette|upright=2|Caches exclusifs]] Dans le cas des '''caches inclusifs''', le contenu d'un cache est recopié dans les caches de niveau inférieur. Par exemple, le cache L1 est recopié dans le cache L2 et éventuellement dans le cache L3. Ce genre de cache a un avantage : le temps d'accès à une donnée est plus faible. La raison est qu'il ne faut pas vérifier tout un cache, mais seulement la partie qui ne contient pas de donnée en doublon. Par exemple, si la donnée voulue n'est pas dans le cache L1, on n'est pas obligé de vérifier la partie du cache L2 qui contient la copie du L1. Ainsi, pas besoin de vérifier certaines portions du cache, ce qui est plus rapide et permet de simplifier les circuits de vérification. En contrepartie, l'inclusion fait que qu'une partie du cache contient des copies inutiles, comme si le cache était plus petit. De plus, maintenir l'inclusion est compliqué et demande des circuits en plus et/ou des échanges de données entre caches. [[File:Caches inclusifs.png|centre|vignette|upright=2|Caches inclusifs]] Maintenir l'inclusion demande de respecter des contraintes assez fortes, ce qui ne se fait pas facilement. Premièrement, toute donnée chargée dans un cache doit aussi l'être dans les caches de niveau inférieur. Ensuite, quand une donnée est présente dans un cache, elle doit être maintenue dans les niveaux de cache inférieurs. De plus, toute donnée effacée d'un cache doit être effacée des niveaux de cache supérieurs : si une donnée quitte le cache L2, elle doit être effacée du L1. Ces trois contraintes posent des problèmes si chaque cache décide du remplacement des lignes de cache en utilisant un algorithme comme LRU, LFU, MRU, ou autre, qui utilise l'historique des accès. En effet, dans ce cas, le cache décide de remplacer les lignes de cache selon l'historique des accès, historique qui varie suivant chaque niveau de cache. Par exemple, une donnée rarement utilisée dans le L2 peut parfaitement être très fréquemment utilisée dans le L1 : la donnée sera alors remplacée dans le L2, mais sera maintenue dans le L1. On observe aussi des problèmes quand il existe plusieurs caches à un seul niveau : chaque cache peut remplacer les lignes de cache d'une manière indépendante des autres caches du même niveau, donnant lieu au même type de problème. Pour maintenir l'inclusion, les caches doivent se transmettre des informations qui permettent de maintenir l'inclusion. Par exemple, les caches de niveaux inférieurs doivent prévenir les niveaux de cache supérieurs quand ils remplacent une ligne de cache. De plus, toute mise à jour dans un cache doit être répercutée dans les niveaux de cache inférieurs et/ou supérieurs. On doit donc transférer des informations de mise à jour entre les différents niveaux de cache. Généralement, le contenu des caches d'instruction n'est pas inclus dans les caches de niveau inférieurs, afin d'éviter que les instructions et les données se marchent sur les pieds. Enfin, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Dans ce cas, l'inclusion est plus difficile à maintenir, pour des raisons assez techniques. ===Les caches eDRAM, sur la carte mère et autres=== D'ordinaire, les mémoires caches sont intégrées au processeur, à savoir que cache et CPU sont dans le même circuit imprimé. Les caches sont donc fabriqués avec de la SRAM, seule forme de mémoire qu'on peut implémenter dans un circuit intégré. Intégrer tous les caches dans le processeur est une solution et efficace. Mais certains processeurs ont procédé autrement. [[File:Cache-on-a-stick module.jpg|vignette|Cache-on-a-stick module]] Des processeurs assez anciens incorporaient un cache L1 dans le processeur, mais plaçaient un cache L2 sur la carte mère. Le cache était clippé sur un connecteur sur la carte mère, un peu comme le sont les barrettes de mémoire. On parlait alors de '''''Cache on a stick''''' (COAST). On aurait pu s'attendre à ce que de tels caches soient en DRAM, vu qu'ils sont placés sur des barrettes de RAM, mais la ressemblance avec la mémoire RAM principale s'arrête là. Le cache était fabriqué en mémoire SRAM, même s'il est en théorie possible de faire de tels caches avec de la DRAM. Les premiers processeurs avec un cache faisaient ainsi, au début des années 90. Il a été introduiot sur les processeurs Motorola, et a été utilisé sur les IBM PC et les Macintosh de l'époque. Les ordinateurs Macintosh utilisaient de tels caches, pour la pluaprt des modèles. Pour ce qui est des PC, les premiers processeurs x86 faisaient pareil, notamment les processeurs Intel. Le 486, le Pentium et le Pentium 2 utilisaient des ''Cache on a stick''. L'avantage est que cela permettait de mettre plus de cache, à une époque où les circuits étaient limités en transistors. De plus, cela permettait au consommateur de choisir quelle quantité de cache il voulait, selon ses finances. Il était possible de laisser le processeur fonctionner soit sans mémoire cache, soit avec un cache de 256 Kibioctets, de 512 Kibioctets, etc. Il était possible d'upgrader le cache si besoin. Pour les CPU Intel, le cache était connecté sur le bus système, au même titre que la mémoire RAM et les entrées-sorties. Il faut dire que les processeurs de l'époque utilisaient un bus système et n'avaient pas de bus mémoire dédié. Mais en théorie, rien n’empêche de connecter le cache sur un bus mémoire dédié. Toujours est-il que les lectures et écritures étaient propagées à la fois dans le cache et la RAM. Les écritures se faisaient dans les deux, systématiquement dans la RAM, mais aussi dans le cache en cas de succès de cache. Les lectures étaient servies soit par le cache en cas de succès de cache, soit par la RAM en cas de défaut de cache. Si le cache répondait en premier, la transaction sur le bus se terminait précocement et l'accès en RAM était abandonné. [[File:Intel486 Иерархия памяти.png|centre|vignette|upright=2.5|Intel486 : le cache était connecté sur le bus système.]] À l'inverse, certains processeurs possédaient un cache fabriqué en mémoire DRAM, et plus précisément avec de la mémoire eDRAM. Le cache n'était pas intégré dans le même circuit imprimé que le processeur, mais profitait d'une architecture en ''chiplet''. Pour rappel, cela veut dire que le processeur est en réalité composé de plusieurs circuits intégré séparés, mais interconnectés et soudés sur un même PCB carré. Avec un cache en eDRAM, le cache avait son propre circuit intégré, séparé du circuit intégré du processeur ou du circuit intégré pour le contrôleur mémoire/IO. Un exemple est celui du cache des processeurs Intel de microarchitecture Broadwell, vus dans ce chapitre dans la section sur les caches splittés. Les tags étaient intégrés dans le circuit intégré du processeur, mais les données étaient mémorisées dans une puce d'eDRAM séparée. La puce eDRAM correspondait en réalité à une DRAM adressable qui servait de DRAM pour les données et mémorisaient les voies du cache. ==Les caches adressés par somme et hashés== Les caches adressés par somme sont optimisés pour incorporer certains calculs d'adresse directement dans le cache lui-même. Pour rappel, certains modes d'adressage impliquent un calcul d'adresse, qui ajoute une constante à une adresse de base. Généralement, l'adresse de base est l'adresse d'un tableau ou d'une structure, et la constante ajoutée indique la position de la donnée dans le tableau/la structure. Les caches hashés et les caches adressés par somme permettent de faire l'addition directement dans la mémoire cache. Voyons d'abord les caches hashés, avant de passer aux caches adressés par somme. Sur les '''caches hashés''', l'addition est remplacée par une autre opération, par exemple des opérations bit à bit du style XOR, AND ou OR, etc. Seulement, utiliser des opérations bit à bit pose un problème : il arrive que deux couples Adresse/décalage donnent le même résultat. Par exemple, le couple Adresse/décalage 11101111/0001 donnera la même adresse que le couple 11110000/0000. Dit autrement, deux adresses censées être différentes (après application du décalage) sont en réalité attribuées à la même ligne de cache. Il est toutefois possible de gérer ces situations, mais cela demande des astuces de haute volée pour faire fonctionner la mémoire cache correctement. Sur les '''caches adressés par somme''', le décodeur est modifié pour se passer de l'addition. Pour comprendre comment, il faut rappeler qu'un décodeur normal est composé de comparateurs, qui vérifient si l'entrée est égale à une constante bien précise. Sur un cache ordinaire, l'addition est faite séparément du décodage des adresses par le cache, dans l'unité de calcul ou dans l'unité de génération d'adresse. [[File:Non sum adressed cache.png|centre|vignette|upright=2|Cache normal.]] Mais les caches adressés par somme modifient le décodeur, qui est alors composé de comparateurs qui testent si la somme adresse + décalage est égale à une constante. [[File:Cache adressé par somme.png|centre|vignette|upright=2|Cache adressé par somme.]] Chaque circuit du décodeur fait le test suivant, avec K une constante qui dépend du circuit : : <math>A + B = K</math> Ce qui est équivalent à faire le test suivant : : <math>A + B - K = 0</math> En complément à deux, on a <math>- K = \overline{K} + 1</math>. En injectant dans l'équation précédente, on a : : <math>A + B + \overline{K} + 1 = 0</math> En réorganisant les termes, on a : : <math>A + B + \overline{K} = - 1</math> Il suffit d'utiliser un additionneur ''carry-save'' pour faire l'addition des trois termes. Rappelons qu'un tel additionneur fournit deux résultats en sortie : une somme calculée sans propager les retenues et les retenues en question. Notons que les retenues sont à décaler d'un cran, vu qu'elles sont censées s'appliquer à la colonne suivante. En notant la somme S et les retenues R, on a: : <math>S + (R << 1) = - 1 </math>, le décalage d'un cran à gauche étant noté <math><< 1</math>. Ensuite, -1 est codé avec un nombre dont tous les bits sont à 1 en complément à un/deux. : <math>S + (R << 1) = 111 \cdots 111111</math> [[File:Sum + retenue add.png|centre|vignette|upright=2|Sum + retenue add]] Un simple raisonnement nous permet de savoir si le résultat est bien -1, sans faire l'addition <math>S + (R << 1)</math>. En effet, on ne peut obtenir -1 que si la somme est l'inverse des retenues : un 0 dans le premier nombre correspond à un 1 dans l'autre, et réciproquement. En clair, on doit avoir <math>\overline{S} = R << 1</math>. Pour vérifier cela, il suffit de faire un simple XOR entre la somme et les retenues décalées d'un cran. On a alors : : <math>S \oplus (R << 1) = 111 \cdots 111111</math> La comparaison avec -1 se fait avec une porte ET à plusieurs entrées. En effet, la porte donnera un 1 seulement si tous les bits d'entrée sont à 1, ce qui est ce qu'on veut tester. Au final, l'additionneur pour l'addition adresse + décalage est remplacé par un additionneur carry-save suivi d'une couche de portes XOR et d'un comparateur avec une constante, ce qui économise de circuits et améliore les performances. [[File:Final circuit of sum addressed cache.png|centre|vignette|upright=2|Cache adressé par somme.]] En prenant en compte que la constante K est justement une constante, certaines entrées de l'additionneur carry-save sont toujours à 0 ou à 1, ce qui permet quelques simplifications à grand coup d’algèbre de Boole. Chaque additionneur complet qui compose l’additionneur carry-save est remplacée par des demi-additionneurs (ou par un circuit similaire). Autant dire que l'on gagne tout de même un petit peu en rapidité, en supprimant une couche de portes logiques. Le circuit de décodage économise aussi des portes logiques, ce qui est appréciable. ==Les caches à accès uniforme et non-uniforme== Intuitivement, le temps d'accès au cache est le même pour toutes les lignes de cache. Il s'agit de cache appelés '''caches à accès uniforme''', sous-entendu à temps d'accès uniforme. Mais sur les caches de grande capacité, il arrive souvent que le temps de propagation des signaux varie fortement suivant la ligne de cache à lire. D'ordinaire, on se cale sur la ligne de cache la plus lente pour caler la fréquence d'horloge du cache, même si on pourrait faire mieux. Cependant, les '''caches à accès non uniforme''' ont une latence différente pour chaque ligne d'un même cache. Certaines lignes de cache sont plus rapides que d'autres. Niveau terminologie, nous allons parler de caches UCA et NUCA : ''Uniform Access Cache'' pour les caches à accès uniforme, ''Non-Uniform Access Cache'' pour les caches à accès non-uniforme. [[File:Caches UCA et NUCA.png|vignette|Caches UCA et NUCA.]] Les caches NUCA et UCA sont souvent composés de plusieurs banques séparées, typiquement une par voie. Sur les caches UCA, les banques sont interconnectées avec le processeur de manière à ce que toutes les interconnexions ont la même longueur pour toutes les banques. Typiquement, les banques sont organisées en carré, avec les interconnexions qui partent du centre, avec une disposition en H, illustrée ci-contre Mais avec les caches NUCA, ce n'est pas le cas. Les interconnexions sont simplifiées et ont des longueurs différentes. Les caches NUCA n'ont pas tous le même genre d'interconnexions, qui dépendent du cache NUCA. En général, les interconnexion forme un réseau avec des sortes de routeurs qui redirigent les données/commandes vers la bonne destination : cache ou processeur. Les banques plus proches du processeur sont accessibles plus rapidement que celles éloignées, même si la différence n'est pas énorme. Les caches NUCA sont généralement associatifs par voie. Les plus simples utilisent une banque par voie pour le cache, ce qui fait que certaines voies répondent plus vite que les autres. La détection des succès de cache est alors plus rapide si la donnée lue/écrite est dans une voie/banque rapide. En théorie, les défauts de cache demandent de vérifier toutes les banques, et se calent donc sur la pire latence. Mais divers caches se débrouillent pour que ce ne soit pas le cas, soit en vérifiant les banquyes unes par une, soit par un mécanisme de recherche plus complexe. Les caches NUCA sont surtout utilisés pour les caches L3 et L4, éventuellement les caches L2. Les caches L1 sont systématiquement des caches UCA, car la latence de l'accès au cache L1 est utilisée par le processeur pour décider quand lancer les instructions. Pour simplifier, le processeur peut démarrer en avance une instruction avant qu'une opérande soit lue dans le cache L1, de manière à ce que la donnée arrive en entrée de l'ALU pile en même temps que l'instruction. Une histoire d'exécution dans le désordre et d'émission anticipée des instructions qu'on détaillera dans une bonne dizaine de chapitres. Toujours est-il que tout est plus simple pour le processeur si le cache L1 a un temps d'accès fixe. Par contre, les caches L3 et L4 sont traités en attendant que les données arrivent, le processeur reprend l'exécution des instructions quand les caches L3 et L4 ont terminé de répondre, pas avant. Avec l'association une banque = une voie, la correspondance ligne de cache → bloc de mémoire qui est statique : on ne peut pas déplacer le contenu d'une ligne de cache dans une autre portion de mémoire plus rapide suivant les besoins. Mais la recherche académique a étudié le cas où la correspondance entre une ligne de cache et une banque varie à l’exécution. Pour nommer cette distinction, on parle de caches S-NUCA (''Static NUCA'') et D-NUCA (''Dynamic NUCA''). Intuitivement, on s'attend à ce que les caches D-NUCA soient plus performants que les caches S-NUCA. Les lignes de cache les plus utilisées peuvent migrer dans une banque rapide, alors que les lignes de cache moins utilisées vont dans une banque éloignée. Les lignes de cache se répartissent dans le cache dynamiquement dans les banques où elles sont le plus adaptées. Mais paradoxalement, le gain des caches D-NUCA est presque nul, voire insignifiant. La raison est que les caches D-NUCA doivent incorporer un système pour déterminer dans quelle banque se situe la donnée pour détecter les succès/défauts de cache, ainsi qu'un système pour migrer les données entre banques. Et ce système augmente le temps d'accès au cache, réduisant à néant l'intérêt d'un cache D-NUCA. Si on économise quelques microsecondes de temps d'accès en passant d'un cache UCA à un cache S-NUCA, ce n'est pas pour les perdre en passant à un D-NUCA. La majorité des caches D-NUCA sont donc en cours de recherche, mais ne sont pas utilisés en pratique. ==La tolérance aux erreurs des caches== Une mémoire cache reste avant tout une mémoire RAM, bien que ce soit de la SRAM. Elle n'est pas parfaite et est donc sujette à des erreurs, qui peuvent inverser un bit ou l'effacer. De telles erreurs sont liées à des rayons cosmiques très énergétiques, à des particules alpha produites par le packaging ou le métal deu circuit intégré, peu importe : l'essentiel est qu'ils inversent parfois un bit. Les mémoires modernes savent se protéger contre de telles erreurs, en utilisant trois moyens. ===Les mémoires caches ECC et à bit de parité=== Le premier moyen est l'usage de codes correcteurs d'erreurs, qui ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Les bits ajoutés dépendent de la donnée mémorisée dans le byte, et servent à détecter une erreur, éventuellement à la corriger. Le cas le plus simple ajoute un simple bit de parité pour chaque byte et se contente de détecter les erreurs dans les corriger. Les autres codes ECC permettent eux de corriger des erreurs, mais ils demandent d'ajouter au moins deux bits par byte, ce qui a un cout en circuit plus élevé. Un simple bit de parité permet de détecter qu'un bit a été inversé, mais ne permet pas de corriger l'erreur. En soi, ce n'est pas un problème. Si une erreur est détectée, on considère que la ligne de cache est invalide. Le cache gère la situation comme un défaut de cache et va chercher la donnée valide en mémoire RAM. Le cout en circuits est donc faible, mais les défauts de cache sont plus nombreux. Les codes ECC sont eux capables de corriger les erreurs, si elles ne modifient pas trop de bits d'un coup. Par contre, ils utilisent deux à trois bits par octet, ce qui a un cout en circuits loin d'être négligeable. Il y a donc un compromis entre défauts de cache et cout en circuits. La gestion de l'ECC est différente suivant le niveau de cache. Généralement, le cache L1 n'utilise pas l'ECC mais se contente d'un simple bit de parité pour éviter la corruption de ses données. Le cache étant petit, les corruptions de données sont assez rares, et les défauts de cache induits faibles. Il est plus important d'utiliser un code de détection d'erreur simple, rapide, qui ne ralentit pas le cache et n'augmente pas sa latence. Si une ligne de cache est corrompue, il a juste à aller lire la ligne depuis le cache L2, ou un niveau de cache inférieur. Du moins, c'est possible sur le cache en question est un cache inclusif et/ou ''write-through''. Par contre, le niveau de cache L2 et ceux en-dessous utilisent presque systématiquement une mémoire SRAM ECC. La raison principale étant que ce sont des caches assez gros, pour lesquels la probabilité d'une erreur est assez élevée. Plus une mémoire a de bits et prend de la place, plus il y a une chance élevée qu'un bit s'inverse. Et vu que les caches L2/L3/L4 sont par nature plus lents et plus gros, ils peuvent se permettre le cout en performance lié à l'ECC, idem pour le cout en circuit. Sans compter qu'en cas d'erreur, ils doivent aller lire la ligne de cache originelle en mémoire RAM, ce qui est très lent ! Mieux vaut corriger l'erreur sur place en utilisant l'ECC. ===L'usage du ''memory scrubbing'' sur les caches=== La plupart des erreurs ne changent qu'un seul bit dans un byte, mais le problème est que ces erreurs s'accumulent. Entre deux accès à une ligne de cache, il se peut que plusieurs erreurs se soient accumulées, ce qui dépasse les capacités de correction de l'ECC. Dans ce cas, il existe une solution appelée le ''memory scrubbing'', qui permet de résoudre le problème au prix d'un certain cout en performance. Pour rappel, l'idée est de vérifier les lignes de caches régulièrement, pour éviter que les erreurs s'accumulent. Par exemple, on peut vérifier chaque ligne de cache toutes les N millisecondes, et corriger une éventuelle erreur lors de cette vérification. En faisant des vérifications régulières, on garantir que les erreurs n'ont pas le temps de s'accumuler, sauf en cas de malchance avec des erreurs très proches dans le temps. Il ne s'agit pas d'un rafraichissement mémoire, car les SRAM ne s'effacent pas), mais ça a un effet similaire. Et évidemment, le ''memory scrubbing'' a un cout en performance. On peut faire une comparaison avec le rafraichissement mémoire : les rafraichissement réguliers réduisent les performances, car cela fait des accès en plus. Des accès qui sont de plus timés à des instants bien précis qui ne sont pas forcément les plus adéquats. Il est possible qu'un rafraichissement ait lieu en même temps qu'un accès mémoire et le rafraichissement a la priorité, ce qui réduit les performances. La même chose arrive avec les vérifications du ''memory scrubbing''. Malgré tout, la technique a été utilisée sur les caches de certains processeurs commerciaux, dont des processeurs AMD Athlon et Athlon 64. Elle est surtout utilisable sur les caches L2/L3, pour lesquels le cout du pseudo-rafraichissement est acceptable. ==Un exemple de cache : le cache d'instruction== La grande majorité des processeurs utilise deux caches L1 séparés : un '''cache d'instructions''' dédié aux instructions, et un autre pour les données. Une telle organisation permet de charger une instruction tout en lisant une donnée en même temps. Notons que seul le cache L1 est ainsi séparé entre cache de données et d'instructions. Le cache d’instruction se situe en théorie entre l'unité de chargement et l'unité de décodage. En effet, ce cache prend en entrée une adresse et fournit une instruction. L'adresse est fournie par le ''program counter'', l'instruction est envoyée dans l'unité de décodage. Le cache se situe donc entre les deux. Le cache de données L1 est connecté au chemin de données, et notamment aux unités de communication avec la mémoire, pas au séquenceur. [[File:Caches L1 et positions dans le processeur.png|centre|vignette|upright=2.5|Caches L1 et positions dans le processeur]] Les deux caches sont reliés au processeur par des bus séparés, l'ensemble ressemble à une architecture Harvard, mais où les caches remplacent les mémoires RAM/ROM. Le cache d'instruction prend la place de la mémoire ROM et le cache de données prend la place de la mémoire RAM. Évidemment, il y a des niveaux de caches en dessous des caches de données/instruction, et ceux-ci contiennent à la fois données et instructions, les deux ne sont pas séparées dans des mémoires/caches séparés. Raison pour laquelle l'ensemble est appelé une '''architecture Harvard modifiée'''. Architecture Harvard, car l'accès aux données et instructions se font par des voies séparées pour le processeur, modifiée car la séparation n'est effective que pour le cache L1 et pas les autres niveaux de cache, et encore moins la RAM. Sur les processeurs modernes, il arrive très souvent que le processeur doive charger une instruction et lire/écrire une donnée en même temps. Et à vrai dire, c'est la règle plus que l'exception. L'usage d'une architecture Harvard modifiée permet cela très facilement : on peut accéder au cache d'instruction via un bus, et au cache de donnée avec l'autre ===Pourquoi scinder le cache L1 en cache d'instruction et de données=== L'usage d'un cache d’instruction séparé du cache de données est à contraster avec l'usage d'un cache L1 multiport unique, capable de mémoriser à la fois instructions et données. Les deux solutions sont possibles ont été utilisées. Les premiers processeurs avaient un cache L1 unique et multiport, mais ce n'est plus le cas sur les processeurs modernes, car les contraintes ne sont pas les mêmes. Le compromis à faire est celui entre deux petits caches rapides et un gros cache plus lent. Pour rappel, plus un cache est petit, plus il est rapide et chauffe moins. Donc au lieu d'utiliser, par exemple, un gros cache lent de 64 Kibioctets, on utilise deux caches de 32 kibioctets, plus rapides. La capacité totale est la même, mais le temps d'accès plus faible. En termes de temps d'accès, la meilleure solution est celle des deux caches simple port. Mais pour ce qui est de l'économie de circuits, c'est moins évident. Entre deux mémoires simple port et une mémoire multiport, la différence en termes de transistors est ambigüe et dépend de la capacité des caches. La différence est surtout notable pour les gros caches, moins pour les petits caches. Il faut aussi tenir compte de la capacité effective. Avec deux caches séparés, la répartition de la capacité du cache L1 est fixée une bonne fois pour toutes. Par exemple, avec un cache d'instruction de 32 KB et un cache de données de 32 KB, impossible d'allouer 40 KB aux données et 20 aux instructions. Alors qu'avec un cache L1 unique de 64 KB, on pourrait le faire sans soucis. La répartition se fait naturellement, en fonction de la politique de remplacement du cache et est proche de l'optimal. C'est là un désavantage des caches d'instructions/données séparés : une capacité effective moindre. Tout cela explique pourquoi le cache L1 est le seul à être ainsi scindé en deux, avec une séparation entre instructions et données : les contraintes au niveau du cache L1 et L2 ne sont pas les mêmes. Pour les caches L1, le temps d'accès est plus important que la capacité, ce qui favorise les caches séparés. Par contre, pour les caches L2/L3/L4, le temps d'accès n'est pas déterminant, alors que la capacité effective et l'économie en circuits sont significatives. ===La connexion des caches L1 avec le cache L2=== Pour les connexions avec le cache L2, tout dépend du processeur. Certains utilisent un cache L2 multiport, qui permet aux deux caches L1 de lire ou écrire dans le cache L2 simultanément. [[File:Cache d'instructions.png|centre|vignette|upright=1.5|Cache d'instructions.]] Si le cache L2 ne gère pas les accès simultanés, il n'y a qu'un seul bus relié aux caches L1 et au cache L2. On doit effectuer un arbitrage pour décider quel cache a la priorité, chose qui est réalisé par un circuit d'arbitrage spécialisé. [[File:Circuit d'arbitrage du cache.png|centre|vignette|upright=1.5|Circuit d'arbitrage du cache.]] Généralement, les caches d'instructions peuvent se permettre d'être plus petits que les caches de données, car les programmes sont souvent plus petits que les données manipulées. Songez que des programmes de quelques mébioctets peuvent parfois remplir la RAM avec plusieurs gibioctets de données. Lancez votre navigateur internet et ouvrez une page web un peu chargée, pour vous en convaincre ! ===Les spécificités du cache d'instruction : lecture seule, bloquant, etc=== Les instructions sont rarement modifiées ou accédées en écritures, contrairement aux données. Et cela permet d'utiliser un cache simplifié pour les instructions. Autant un cache généraliste doit permettre les lectures et écritures depuis le processeur (avec les échanges avec la RAM), autant un cache d'instruction peut se contenter des lectures provenant du CPU et des échanges avec la RAM. Le cache d'instructions est donc très souvent en « lecture seule » : le processeur ne peut pas écrire dedans, mais juste le lire ou charger des instructions dedans. Un cache d'instruction est donc plus simple qu'un cache pour les données : on peut retirer les circuits en charge de l'écriture (mais on doit laisser un port d'écriture pour charger les instructions dedans). Le gain en circuits permet d'utiliser un cache d'instruction plus gros ou au contraire de laisser de la place pour le cache de données. Le gain en termes de capacité compense alors un peu les inconvénients des caches séparés. Par contre, cela complique la gestion du code automodifiant, c'est-à-dire des programmes dont certaines instructions vont aller en modifier d'autres, ce qui sert pour faire de l'optimisation ou est utilisé pour compresser ou cacher un programme (les virus informatiques utilisent beaucoup de genre de procédés). Quand le processeur exécute ce genre de code, il ne peut pas écrire dans ce cache L1 d'instructions, mais doit écrire dans le cache L2 ou en RAM, avant de recharger les instructions modifiées dans le cache L1. Cela qui prend du temps et peut parfois donner lieu à des erreurs si le cache L1 n'est pas mis à jour. Les algorithmes de remplacement des lignes de cache optimaux pour les données ne le sont pas pour les instructions, de même que la taille optimale du cache, la taille des lignes de cache optimale, ou même les algorithmes de préchargement. Par exemple, pour le remplacement des lignes de cache, un simple algorithme LRU est presque optimal pour les instructions, autant il peut donner de mauvaises performances quand on manipule beaucoup de tableaux. Cela justifie d'utiliser des caches spécialisés pour chacune. On peut adapter le cache d'instruction à son contenu, ce qui le rend plus rapide ou plus petit à performance égale. Les caches d'instructions sont généralement des caches bloquants. Il ne servirait à rien de rendre un cache d'instruction non-bloquant, le cout en circuits ne se traduirait pas par une augmentation significative des performances. À l'opposé, les caches de données sont non-bloquants sur les architectures modernes, pour des raisons de performance. Ce qui rend la séparation assez intéressante, les deux caches ayant des besoins différents et des implémentations différentes, cela permet d'optimiser le cout en transistors des caches. ===L'impact du cache d'instruction sur les performances=== Sur les architectures conventionnelles, le cache d'instruction a plus d'impact sur les performances que le cache de données. La raison principale est que les instructions ont une meilleure localité spatiale et temporelle que pour les données. Pour la localité spatiale, les instructions consécutives se suivent en mémoire, alors que rien ne garantit que des données utilisées ensemble soient regroupées en mémoire. Pour localité temporelle, elle est très variable pour les données, mais très courante pour les instructions du fait de l'usage fréquent des boucles et des fonctions. : La présence de branchements atténue la localité temporelle des instruction, sauf que la majorité des branchements sautent à un endroit très proche, seuls les appels de fonction brisent la localité spatiale. La conséquence est qu'il arrive que certains CPU aient un cache L1 d'instruction plus gros que celui pour les données. On parle alors de '''cache L1 asymétriques'''. Un exemple est celui des processeurs AMD de microarchitecture Zen, dont le cache d'instruction était deux fois plus gros que le cache de données. Leur cache d'instruction faisait 64 kibioctets, contre seulement 32 pour le cache de données. D'ailleurs, il existe des processeurs assez extrêmes qui se contentent d'un cache d'instruction unique, sans cache de données. C'est le cas sur les processeurs vectoriels ou les GPU que nous verrons dans les chapitres de fin de ce wikilivres. De tels processeurs sont spécialisés dans la manipulation de tableaux de données, traitement qui a une faible localité temporelle. En conséquence, utiliser un cache de données n'est pas vraiment utile, voire peu être contreproductif, alors qu'un cache d’instruction fonctionne parfaitement. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Compléments sur les mémoires de masse | prevText=Compléments sur les mémoires de masse | next=Le préchargement | nextText=Le préchargement }} </noinclude> tvuqa3m5ytxhaeqj35xi9ggiciaf0xl 765130 765129 2026-04-26T16:44:05Z Mewtow 31375 /* Le contrôleur de cache 82385 pour les CPU Intel 386 */ 765130 wikitext text/x-wiki Le cache est une mémoire intercalée entre la mémoire et un processeur, plus rarement à l'intérieur d'un périphérique. Il est souvent fabriquée avec de la mémoire SRAM, parfois avec de l'eDRAM. Sans lui, on se croirait à l'âge de pierre tellement nos PC seraient lents ! En effet, la mémoire est très lente comparée au processeur. Le temps mis pour accéder à la mémoire est du temps durant lequel le processeur n'exécute pas d'instruction (sauf cas particuliers impliquant un pipeline). Pour diminuer ce temps d'attente, il a été décidé d'intercaler une mémoire petite mais rapide, entre le processeur et la mémoire. Ainsi, le processeur accède à un cache très rapide plutôt qu'à une RAM beaucoup plus lente. ==L'accès au cache== Le cache contient une copie de certaines données présentes en RAM. La copie présente dans le cache est accessible bien plus rapidement que celle en RAM, vu que le cache est plus rapide. Mais seule une petite partie de ces données sont copiées dans le cache, les autres données devant être lues ou écrites dans la RAM. Toujours est-il que le cache contient une copie des dernières données accédées par le processeur. Une donnée est copiée dans la mémoire cache quand elle est lue ou écrite par le processeur. Le processeur conserve une copie de la donnée dans le cache après son premier accès. Les lectures/écritures suivantes se feront alors directement dans le cache. Évidemment, au fur et à mesure des accès, certaines données anciennes sont éliminées du cache pour faire de la place aux nouveaux entrants, comme nous le verrons plus tard. [[File:Principe d'une mémoire cache.gif|centre|vignette|upright=2|Principe d'une mémoire cache.]] La mémoire cache est invisible pour le programmeur, qui ne peut pas déceler celles-ci dans l'assembleur. Les accès mémoire se font de la même manière avec ou sans le cache. La raison à cela est que le cache intercepte les accès mémoire et y répond s'il en a la capacité. Par exemple, si le cache intercepte une lecture à une adresse et que le contenu de cette adresse est dans le cache, le cache va outrepasser la mémoire RAM et la donnée sera envoyée par le cache au lieu d'être lue en RAM. par contre, si un accès se fait à une adresse pour laquelle le cache n'a pas la donnée, alors l'accès mémoire sera effectué par la RAM de la même manière que si le cache n'était pas là. [[File:Accès au cache.png|centre|vignette|upright=2|Accès au cache]] ===Les succès et défauts de caches=== Tout accès mémoire est intercepté par le cache, qui vérifie si la donnée demandée est présente ou non dans le cache. Si la donnée voulue est présente dans le cache, on a un '''succès de cache''' (''cache hit'') et on accède à la donnée depuis le cache. Sinon, c'est un '''défaut de cache''' (''cache miss'') et on est obligé d’accéder à la RAM. Les défauts de cache peuvent avoir plusieurs origines. Tout ce qu'il faut savoir est que lorsque le processeur accède à une donnée ou une instruction pour la première fois, il la place dans la mémoire cache car elle a de bonnes chances d'être réutilisée prochainement. La raison à cela est qu'un programme a tendance à réutiliser les instructions et données qui ont été accédées dans le passé : c'est le ''principe de localité temporelle''. Bien évidement, cela dépend du programme, de la façon dont celui-ci est programmé et accède à ses données et du traitement qu'il fait, mais c'est souvent vrai en général. La première cause des défauts de cache est liée à la taille du cache. À force de charger des données/instructions dans le cache, le cache fini par être trop petit pour conserver les anciennes données. Le cache doit bien finir par faire de la place en supprimant les anciennes données, qui ont peu de chances d'être réutilisées. Ces anciennes données éliminées du cache peuvent cependant être accédées plus tard. Tout prochain accès à cette donnée mènera à un cache miss. C'est ce qu'on appelle un ''Capacity Cache Miss'', ou encore '''défaut de capacité'''. Les seules solutions pour éviter cela consistent à augmenter la taille du cache ou à optimiser le programme exécuté (voir plus bas). Une autre raison pour un défaut est donc la suivante. Lorsqu'on exécute à une instruction ou qu'on accède à donnée pour la première fois, celle-ci n'a pas encore été chargée dans le cache. Le défaut de cache est inévitable : ce genre de cache miss s'appelle un ''Cold Miss'', ou encore un '''défaut à froid'''. De tels défauts sont presque impossibles à éliminer, sauf à utiliser des techniques de préchargement qui chargent à l'avance des données potentiellement utiles. Ces méthodes de préchargement se basent sur le principe de localité spatiale, à savoir le fait que les programmes ont tendance à accéder à des données proches en mémoire. Pour donner un exemple, les instructions d'un programme sont placées en mémoire dans l’ordre dans lequel on les exécute : la prochaine instruction à exécuter est souvent placée juste après l'instruction en cours (sauf avec les branchements). Quand on accède à une donnée ou une instruction, le cache peut précharger les données adjacentes pour en profiter. Nous parlerons de ces techniques de préchargement dans un chapitre dédié, vers la fin du cours. ===Le fonctionnement du cache, vu du processeur=== Vu du processeur, le cache prend en entrée toutes les informations nécessaires pour effectuer un accès mémoire : des signaux de commande, une adresse et la donnée à écrire si besoin. Tout cela est passé en entrée du cache, celui-ci répondant aux accès mémoire via divers bits de contrôles, que le processeur peut lire à souhait. Le cache fournit aussi la donnée à lire, pour les lectures, sur une sortie, connectée directement au bus mémoire/processeur. Globalement, le cache a une capacité limitée, mais il prend en entrée des adresses complètes. Par exemple, sur un processeur 64 bits, le cache prend en entrée des adresses de 64 bits (sauf si optimisations), même si le cache en question ne fait que quelques mébioctets. Les caches sont souvent des mémoires multiports, surtout sur les processeurs récents. Les caches simple port sont rares, mêmes s'ils existent et ont existé par le passé. les caches double port sont eux plus fréquents, et ont généralement un port d'écriture séparé du port de lecture. Mais les caches récents ont plusieurs ports de lecture/écriture et sont capables de gérer plusieurs accès mémoire simultanés. Les données présentes dans le cache sont (pré)chargées depuis la mémoire, ce qui fait que toute donnée dans le cache est la copie d'une donnée en mémoire RAM. Le cache doit faire la correspondance entre une donnée du cache et l'adresse mémoire correspondante. Du point de vue du fonctionnement, on peut voir le cache comme une sorte de table de correspondance, qui mémorise des données, chacune étant associée à son adresse mémoire. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Cela vaut du point de vue du processeur, le fonctionnement interne du cache étant quelque peu différent selon le cache. Il existe des caches dont le fonctionnement interne est bien celui d'une table de correspondance matérielle, d'autres qui sont beaucoup plus optimisés. [[File:Fonctionnement d'une mémoire associative à correspondance.png|centre|vignette|upright=2|Fonctionnement simplifié d'une mémoire cache : les adresses sont dans la colonne de gauche, les données sont dans la colonne de droite. On voit qu'on envoie l'adresse au cache, que celui-ci répond en renvoyant la donnée associée.]] ==La performance des mémoires caches== L'analyse de la performance des mémoires caches est plus riche pour celle des autres mémoires. Sa performance dépend de beaucoup de paramètres, mais on peut cependant citer les principaux. Les deux premiers sont tout bonnement sa latence et son débit, comme pour n'importe quelle autre mémoire. La latence est plus importante que son débit, car le processeur est généralement plus rapide que le cache et qu'il n'aime pas attendre. Mais le critère le plus important pour un cache est sa capacité à empêcher des accès mémoire, son efficacité. Plus les accès mémoire sont servis par le cache au lieu de la RAM, meilleures seront les performances. Pour résumer, la performance d'un cache est surtout caractérisée par deux métriques : le taux de défaut, qui correspond à l’efficacité du cache, et la latence du cache. ===Le taux de succès/défaut=== Le '''taux de succès''' (hit ratio) est un premier indicateur des performances du cache, mais un indicateur assez imparfait. C'est le pourcentage d'accès mémoire qui ne déclenchent pas de défaut de cache. Plus il est élevé, plus le processeur accède au cache à la place de la RAM et plus le cache est efficace. Certains chercheurs préfèrent utiliser le '''taux de défauts''', à savoir le pourcentage d'accès mémoire qui entraînent un défaut de cache. Plus il est bas, meilleures sont les performances. Le taux de défaut est relié au taux de succès par l'équation <math>T_\text{succes} = 1 - T_\text{defaut}</math>. Par définition, il est égal à : : <math>\text{Taux de défauts de cache} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d’accès mémoires}}</math> Plutôt que de comparer le nombre de défauts/succès de cache au nombre d'accès mémoire, il est aussi possible de diviser le nombre de défauts par le nombre total d'instructions. On obtient alors le '''taux de défauts/succès par instruction''', une autre métrique utile. Par définition, elle est égale à : : <math>\text{Taux de défauts par instruction} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d'instructions}} = \text{Taux de défauts de cache} \times \frac{\text{Nombre d’accès mémoires}}{\text{Nombre d'instructions}}</math> Si certains défauts de cache sont inévitables quel que soit le cache, comme les défauts à froids, mentionnés plus haut, d'autres défauts peuvent être évités en augmentant la capacité du cache. C'est le cas des défauts de capacité qui sont causés par un accès à une donnée qui a été éliminée du cache faute de place. Plus le cache est gros, moins il a de chances d'être rempli, moins il doit rapatrier de données, plus son taux de succès augmente. Mais nous reviendrons sur le lien entre taille du cache et taux de défaut plus bas. Le taux de succès ne dépend pas que du cache, mais aussi de la conception des programmes exécutés. Une bonne utilisation du cache (ainsi que de la mémoire virtuelle) repose sur le programmeur qui doit prendre en compte les principes de localités dès la conception de ses programmes. Par exemple, un programmeur peut parfaitement tenir compte du cache au niveau de son algorithme : on peut citer l'existence des algorithmes ''cache oblivious'', qui sont conçus pour être optimaux quelle que soit la taille du cache. Le programmeur peut aussi choisir ses structures de données de manière à améliorer la localité. Par exemple, un tableau est une structure de donnée respectant le principe de localité spatiale, tandis qu'une liste chaînée ou un arbre n'en sont pas (bien qu'on puisse les implémenter de façon à limiter la casse). D'autres optimisations sont parfois possibles : par exemple, le sens de parcours d'un tableau multidimensionnel peut faire une grosse différence. Cela permet des gains très intéressants pouvant se mesurer avec des nombres à deux ou trois chiffres. Je vous recommande, si vous êtes programmeur, de vous renseigner le plus possible sur les optimisations de code ou algorithmiques qui concernent le cache : il vous suffira de chercher sur Google. Il y a une citation qui résume bien cela, prononcée par un certain Terje Mathisen. Si vous ne le connaissez pas, cet homme est un vieux programmeur (du temps durant lequel on codait encore en assembleur), grand gourou de l’optimisation, qui a notamment travaillé sur le moteur de Quake 3 Arena. {{BlocCitation|Almost all programming can be viewed as an exercise in caching.|auteur=Terje Mathisen}} ===La latence moyenne d'un cache=== Le temps mis pour lire ou écrire une donnée varie en présence d'un cache. Certaines lectures/écritures vont atterrir directement dans le cache (succès) tandis que d'autres devront aller chercher leur contenu en mémoire RAM (défaut de cache). Dans tous les cas, qu'il y ait défaut ou non, le cache sera consulté et mettra un certain temps à répondre, égal au temps de latence du cache. Tous les accès mémoires auront donc une durée au moins égale au temps de latence du cache, qui sera notée <math>T_c</math>. En cas de succès, le cache aura effectué la lecture ou l'écriture, et aucune action supplémentaire n'est requise. Ce qui n'est pas le cas en cas de défaut : le processeur devra aller lire/écrire la donnée en RAM, ce qui prend un temps supplémentaire égal au temps de latence de la mémoire RAM. Un défaut ajoute donc un temps, une pénalité, à l'accès mémoire. Dans ce qui suivra, le temps d'accès à la RAM sera noté <math>T_m</math>. Fort de ces informations, nous pouvons calculer le temps de latence moyen d'un accès mémoire, qui est la somme du temps d'accès au cache (pour tous les accès mémoire), multiplié par le temps lié aux défauts. On a alors : : <math>T = T_c + \text{Taux de défaut} \times T_m</math> On voit que plus le taux de succès est élevé, plus le temps de latence moyen sera bas, et inversement. Ce qui explique l'influence du taux de succès sur les performances du cache, influence assez importante sur les processeurs actuels. De nos jours, le temps que passe le processeur dans les défauts de cache devient de plus en plus un problème au fil du temps, et gérer correctement le cache est une nécessité, particulièrement sur les processeurs multi-cœurs. Il faut dire que la différence de vitesse entre processeur et mémoire est tellement importante que les défauts de cache sont très lents : alors qu'un succès de cache va prendre entre 1 et 5 cycles d'horloge, un cache miss fera plus dans les 400-1000 cycles d'horloge. Tout ce temps sera du temps de perdu que le processeur aura du mal à mitiger. Autant dire que réduire les défauts de cache est beaucoup plus efficace que d'optimiser les calculs effectués par le processeur (erreur courante chez de nombreux programmeurs, notamment débutants). ===L'impact de la taille du cache sur le taux de défaut et la latence=== Il y a un lien entre taille du cache, taux de défaut, débit binaire et latence moyenne. Globalement, plus un cache est gros, plus il est lent. Simple application de la notion de hiérarchie mémoire vue il y a quelques chapitres. Les raisons à cela sont nombreuses, mais nous ne pouvons pas les aborder ici, car il faudrait que nous sachions comment fonctionne un cache et ce qu'il y a à l'intérieur, ce qui sera vu dans la suite du chapitre. Toujours est-il que la latence moyenne d'un cache assez gros est assez importante. De même, le débit binaire d'un cache diminue avec sa taille, mais dans une moindre mesure. Les petits caches ont donc un gros débit binaire et une faible latence, alors que c'est l'inverse pour les gros caches. Une grande capacité de cache améliore le taux de succès, mais cela se fait au détriment de son temps de latence et de son débit, ce qui fait qu'il y a un compromis assez difficile à trouver entre taille du cache, latence et débit. Il peut arriver qu'augmenter la taille du cache augmente son temps d'accès au point d’entraîner une baisse de performance. Par exemple, les processeurs Nehalem d'Intel ont vus leurs performances dans certains jeux vidéos baisser de 2 à 3 %, malgré de nombreuses améliorations architecturales, parce que la latence du cache L1 avait augmentée de 2 cycles d'horloge. Pour avoir une petite idée du compromis à faire, regardons la relation entre taille du cache et taux de défaut. Il existe une relation approximative entre ces deux variables, appelée la '''loi de puissance des défauts de cache'''. Elle donne le nombre total de défaut de cache en fonction de la taille du cache et de deux autres paramètres. Voici cette loi : : <math>\text{Taux de défauts de cache} \approx K \times \text{Taille du cache}^{- \alpha }</math>, avec <math>K</math> et <math>\alpha</math> deux coefficients qui dépendent du programme exécuté. Le coefficient <math>\alpha</math> est généralement compris entre 0.3 et 0.7, guère plus, et varie suivant le programme exécuté. Précisons que cette loi ne marche que si le cache est assez petit par rapport aux données à utiliser. Pour un cache assez gros et des données très petites, la relation précédente est mise en défaut. Pour s'en rendre compte, il suffit d'étudier le cas extrême où toutes les données nécessaires tiennent dans le cache. Dans ce cas, il n'y a qu'un nombre fixe de défauts de cache : autant qu'il faut charger de données dans le cache. Le nombre de défauts de cache observé dans cette situation n'est autre que le coefficient <math>K</math> de la situation précédente, mais il n'y a aucune dépendance entre taux de défaut et taille du cache. L'origine de cette relation s'explique quand on regarde combien de fois chaque donnée est réutilisée lors de l’exécution d'un programme. La plupart des données finissent par être ré-accédées à un moment ou un autre et il se passe un certain temps entre deux accès à une même donnée. Sur la plupart des programmes, les observations montrent que beaucoup de réutilisations de données se font après un temps très court et qu'inversement, peu de ré-accès se font après un temps inter-accès long. Si on compte le nombre de réutilisation qui ont un temps inter-accès bien précis, on retrouve une loi de puissance identique à celle vue précédemment : : <math>\text{Nombre de réaccès avec un temps inter-accès égal à t} \approx K \times t^{- \beta}</math>, avec t le temps moyen entre deux réutilisations. Le coefficient <math>\beta</math> est ici compris entre 1.7 et 1.3. De manière générale, les coefficients <math>\alpha</math> et <math>\beta</math> sont reliés par la relation <math>\alpha = 1 - \beta</math>, ce qui montre qu'il y a un lien entre les deux relations. Précisons cependant que la loi de puissance précédente ne vaut pas pour tous les programmes informatiques, mais seulement pour la plupart d’entre eux. Il n'est pas rare de trouver quelques programmes pour lesquels les accès aux données sont relativement prédictibles et où une bonne optimisation du code fait que la loi de puissance précédente n'est pas valide. La loi de puissance des défauts de cache peut se démontrer à partir de la relation précédente, sous certaines hypothèses. Si un suppose que le cache est assez petit par rapport aux données, alors les deux relations sont équivalentes. L'idée qui se cache derrière la démonstration est que si le temps entre deux accès à une donnée est trop long, alors la donnée accédée aura plus de chance d'être rapatriée en RAM, ce qui cause un défaut de cache. La chance de rapatriement dépend de la taille du cache, un cache plus gros peut conserver plus de données et a donc un temps avant rapatriement plus long. ==Les lignes de cache et leurs tags== Du point de vue du processeur, les lectures et écritures se font mot mémoire par mot mémoire. Un processeur avec des entiers de 64 bits recoit des données de 64 bits de la part du cache, et y écrit des mots de 64 bits. Mais quand on regarde comment sont stockées les données à l'intérieur du cache, les choses sont différentes. ===Les lignes de cache=== Les données sont mémorisées dans le cache par blocs de plusieurs bytes, d'environ 64 à 256 octets chacun, qui portent le nom de '''lignes de cache'''. Les lignes de cache sont l'unité de stockage que l'on trouve à l'intérieur du cache, mais elles servent aussi d'unité de transaction avec la mémoire RAM. Sur les caches actuels, on transfère les données entre le cache et la RAM ligne de cache par ligne de cache, dans la limite de la taille du bus mémoire. Mais d'autres caches plus anciens permettaient de faire des transferts plus fins. C’est-à-dire qu'on pouvait mettre à jour quelques octets dans une ligne de cache sans avoir à la recopier intégralement depuis ou dans la mémoire RAM. En théorie, on pourrait imaginer des caches où les données sont stockées différemment, où l'unité serait le mot mémoire, par exemple. Par exemple, sur un processeur 64 bits, on aurait une ligne de cache de 64 bits. Cela aurait l'avantage de la simplicité : les transferts entre le processeur et la mémoire serait de même taille, l'intérieur du cache ressemblerait à son interface montrée au processeur. Mais cela aurait quelques défauts qui sont compensés par l'organisation en lignes de cache de grande taille. Le premier avantage des lignes de cache est lié à la localité spatiale, la tendance qu'on les programmes à accéder à des données proches les unes des autres. Des accès mémoires consécutifs ont tendance à se faire à des adresses proches, qui ont de bonnes chances d'être dans la même ligne de cache. Et des accès consécutifs à une même ligne de cache sont plus rapides que des accès à deux lignes distinctes. Une autre raison est tout simplement que cela simplifie considérablement la circuiterie du cache. Pour une capacité identique, il vaut mieux avoir peu de lignes de cache assez grosses, que beaucoup de petites lignes de cache. La raison est que les circuits du cache, comme le décodeur, l'encodeur et autres, ont moins de sorties et sont donc plus simples. ===L'alignement des lignes de cache=== Les lignes de cache sont des blocs de plusieurs dizaines à centaines de bytes, dont la taille est presque toujours une puissance de deux. De plus, les lignes de cache sont alignées en mémoire. Nous avions déjà abordé la notion d'alignement mémoire dans un chapitre précédent, mais le concept d'alignement des lignes de cache est quelque peu différent. Quand nous avions parlé d'alignement auparavant, il s'agissait de l'alignement des données manipulées par le processeur, qui faisait partie du jeu d'instruction du processeur. Ici, nous parlons d'un alignement totalement différent, invisible pour le programmeur, sans lien avec le jeu d’instruction. Voyons de quoi il retourne. Concrètement, cela veut dire que du point de vue du cache, la RAM est découpée en blocs qui font la même taille qu'une ligne de cache, aux positions prédéterminées, sans recouvrement entre les blocs. Par exemple, pour un cache dont les lignes de cache font 256 octets, le premier bloc est à l'adresse 0, le second est 256 octets plus loin, c'est à dire à l'adresse 256, le troisième à l'adresse 512, la quatrième à l'adresse 768, etc. Une ligne de cache de 256 octets contiendra une donnée provenant d'un bloc de RAM de 256 octets, dont l'adresse est systématiquement un multiple de 256. Il n'est pas possible qu'une ligne de cache contienne un bloc de 256 octets dont l'adresse du premier octet serait l'adresse 64, ou l'adresse 32, par exemple. En clair, les adresses de ces blocs sont des multiples de la taille de la ligne de cache, de la taille des blocs. Cela rappelle les contraintes d'alignement vues dans le chapitre "Le modèle mémoire : alignement et boutisme", mais appliquées aux lignes de cache. L'alignement des lignes de cache a des conséquences pratiques pour la conception des caches. Notons qu'il est en théorie possible d'avoir des caches dont les lignes de cache ne sont pas alignées, mais cela poserait des problèmes majeurs. Il serait en effet possible qu'une donnée soit présente dans deux lignes de cache à la fois. Par exemple, prenons le cas où une ligne de cache de 256 commence à l'adresse 64 et une autre ligne de cache commence à l'adresse 0. L'adresse 128 serait dans les deux lignes de cache ! Et cela poserait des problèmes lors des lectures, mais encore plus lors des écritures. C'est pour éviter ce genre de problèmes que les lignes de cache sont alignées avec la mémoire RAM dans tous les caches existants. L'alignement des lignes de cache est une chose que les programmeurs doivent parfois prendre en compte quand ils écrivent du code ultra-optimisé, destiné à des programmes demandant des performances extrêmes. Il arrive que les contraintes d'alignement posent des problèmes. Nous avions vu dans le chapitre sur le boutisme et l'alignement qu'il valait mieux gérer l'alignement des variables des structures de données, pour éviter les accès non-alignés avec le bus mémoire. La même chose est possible, mais pour l'alignement avec des lignes de cache. Typiquement, l'idéal est que, pour une structure de donnée, on puisse en mettre un nombre entier dans une ligne de cache. Ou alors, si la structure est vraiment grande, que celle-ci occupe un nombre entier de lignes de cache. Si ce n'est pas le cas, il y a un risque d'accès non-alignés, c'est à dire qu'une structure se retrouve à cheval sur deux lignes de cache, avec les défauts que cela implique. ===Le tag d'une ligne de cache=== Plus haut, nous avions dit que le cache mémorise, pour chaque ligne de cache, l'adresse RAM associée. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Mais du fait de l'organisation du cache en lignes de cache de grande taille, qui sont de plus alignées en mémoire, il faut nuancer cette affirmation. Le cache ne mémorise pas la totalité de l'adresse, ce qui serait inutile. L'alignement des lignes de cache en RAM fait que les bits de poids faible de l'adresse ne sont pas à prendre en compte pour l'association adresse-ligne de cache. Dans ces conditions, on mémorise seulement la partie utile de l'adresse mémoire correspondante, qui forme ce qu'on appelle le '''tag'''. Le reste de l'adresse indique quelle est la position de la donnée dans la ligne de cache. Par exemple, prenons le cas où le processeur gère des nombres entiers de 64 bits (8 octets) et des lignes de cache de 128 octets : chaque ligne de cache contient donc 16 entiers. Si le processeur veut lire ou écrire un entier bien précis, il doit préciser sa place dans la ligne de cache. Et ce sont les bits de l'adresse mémoire non-inclus dans le cache qui permettent de faire ça. En clair, une adresse mémoire à lire/écrire est interprété par le cache comme la concaténation d'un tag et de la position de la donnée dans la ligne de cache correspondante. [[File:Adressage d'un cache totalement associatif.png|centre|vignette|upright=2|Adressage d'un cache totalement associatif]] Le cache est donc une grande table de correspondance entre tags et lignes de cache. Lors d'un accès mémoire, le cache extrait le tag de l'adresse à lire ou écrire, et le compare avec les tags de chaque ligne de cache. Si une ligne contient ce tag, alors c'est que cette ligne correspond à l'adresse, et c'est un défaut de cache sinon. Lors d'un succès de cache, la ligne de cache est lue depuis le cache et envoyée à un multiplexeur qui sélectionne la donnée à lire dans la ligne de cache. Le fonctionnement est similaire pour une écriture : la donnée à écrire passe dans un démultiplexeur, qui envoie la donnée au bon endroit dans la ligne de cache sélectionnée. [[File:Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.png|centre|vignette|upright=2|Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.]] ===Le contenu d'une ligne de cache=== Dans ce qui va suivre, nous allons considérer que chaque ligne de cache mémorise son tag, les données de la ligne de cache proprement dit, et quelques bits de contrôle annexes qui varient suivant le cache considéré. [[File:Tag d'une ligne de cache.png|centre|vignette|upright=2|Tag d'une ligne de cache.]] Les caches modernes incluent de nombreux bits de contrôle, mais deux d'entre eux sont communs à presque tous les caches modernes : le bit ''Dirty'' et le bit ''Valid''. Le '''bit ''Valid''''' indique si la ligne de cache contient des données valides ou non. Si le bit ''Valid'' est à 0, la ligne de cache est en état valide, à savoir qu'elle contient des données et n'est pas vide. Par contre, si ce bit est à 1, la ligne de cache est invalide et son contenu ne peut pas être lu ou écrit. L'utilité de ce bit est qu'il permet d'effacer une ligne de cache très rapidement : il suffit de mettre ce bit à 0. Il existe des situations où le cache doit être effacé, on dit alors qu'il est invalidé. Une section de ce chapitre sera dédié à l'invalidation du cache. Le '''bit ''Dirty''''' indique qu'une ligne de cache a été modifiée. Par modifiée, on veut dire que le processeur a écrit dedans, qu'il a modifié la ligne de cache. Mais attention : si la donnée a été modifiée dans le cache, la modification n'est pas forcément propagée en mémoire RAM. Le bit ''dirty'' indique si c'est le cas, si l'écriture a été propagée en mémoire RAM. Il précise que la ligne de cache contient des données modifiées, alors que la RAM a des données initiales non-modifiées. Une ligne de cache avec un bit ''dirty'' à 1 est dite ''dirty'', par métonymie. Nous verrons cela en détail dans la section sur les caches ''write-back'' et ''write-through''. Les caches modernes ajoutent des '''bits de détection/correction d'erreur''' dans les bits de contrôle. Pour rappel, les codes de détection/correction d'erreur permettent de se prémunir contre des erreurs matérielles, qui corrompent les données stockées dans une mémoire, ici une mémoire cache. Ils ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Nous reviendrons dessus dans une section ultérieur de ce chapitre. Sur certains caches assez anciens, on pouvait transférer les lignes de caches morceaux par morceaux. Ces caches avaient des lignes de cache divisées en sous-secteurs, ces sous-secteurs étant des morceaux de ligne de cache qu'on pouvait charger indépendamment les uns des autres (mais qui sont consécutifs en RAM). Chaque secteur avait ses propres bits de contrôle, mais le tag était commun à tous les secteurs. [[File:Cache à secteurs.png|centre|vignette|upright=2.5|Cache à secteurs.]] : Dans ce qui va suivre, le terme "ligne de cache" désignera soit un bloc de données copiées depuis la RAM d'une taille de 64/128/256/... octets, soit la concaténation de ces données avec le tag et des bits de contrôle. Les deux définitions ne sont pas équivalentes, mais l'usage a entériné cet abus de langage. Et il faut avouer que cela rend les explications du chapitre plus simples. ==Les instructions de contrôle du cache== Plus haut, nous avions dit que le cache est totalement transparent du point de vue du programmeur. Le cache contient des copies de données en RAM, le programmeur n'a rien à faire pour utiliser le cache correctement. Mais la réalité est que pour des raisons diverses, des processeurs incorporent des '''instructions de contrôle du cache'''. Il s'agit d’instructions qui agissent sur le contenu du cache. Elles existent pour des raisons diverses qu'on détaillera plus bas, mais il s'agit globalement d'une question de performances ou de nécessité pour le système d'exploitation. ===Les instructions de préchargement=== La première instruction de contrôle du cache est une '''instruction de préchargement''', qui demande à charger un bloc de données dans le cache. Elle prend en opérande une adresse mémoire, et le contenu de cette adresse est chargé dans une ligne de cache. Bien sûr, des contraintes d'alignement sont à prendre en compte : on charge un bloc de la même taille qu'une ligne de cache, aligné en mémoire sur la taille du bloc, qui contient l'adresse. L'instruction de préchargement n'est utile que si l'instruction est exécutée bien avant que la donnée ne soit utilisée/lue/écrite. Cela permet de charger une donnée dans le cache à l'avance, d'où le nom de préchargement donné à cette technique. Mais les processeurs modernes gérent des techniques de préchargement automatique, qui ne requièrent pas d'instructions de préchargement. Le préchargement automatique et les instructions de préchargement sont deux solutions complémentaires, mais qui peuvent se marcher sur les pieds. Nous en reparlerons dans le prochain chapitre, qui sera dédié au préchargement automatique. Il faut noter que les instructions de préchargement peuvent être ignorées par le processeur. Sous certaines conditions, le processeur peut décider que l'instruction de préchargement ne sera pas exécutée. Par exemple, il ne va pas précharger une donnée déjà présente dans le cache. Ou encore, si le bus mémoire est occupé, il ne va pas exécuter le préchargement, par manque de ressources matérielles. ===Les instructions d'invalidation et de ''flush''=== Les instructions ''flush'' regroupent deux types d'instructions qui sont souvent utilisées en même temps. Il s'agit des instructions d'invalidation et de nettoyage (''clean''). Les deux termes proviennent de la terminologie ARM, il n'y a pas de terminologie standardisé pour les noms de ces instructions. Dans les grandes lignes, elles permettent de vider le cache, à savoir de rapatrier son contenu en RAM et de réinitialiser le cache à zéro. Elles sont utilisées par le système d'exploitation lors des commutations de contexte, à savoir quand on passe d'un programme à un autre. Elles sont aussi utilisées lors des appels systèmes et routines d'interruption/exception. L'idée est de vider le cache avant d'exécuter un nouveau programme ou une nouvelle routine. Le nouveau programme aura accès à un cache tout propre, les données de l'ancien programme auront été retirée du cache. Les '''instructions ''clean''''' recopient le contenu de la ligne de cache en RAM. Elles forcent la recopie immédiatement de la ligne de cache en mémoire RAM. Pour faire leur travail, elle vérifient si la ligne de cache a été modifiée, avant de la recopier en RAM. Et pour cela, ils vérifient le bit de contrôle ''dirty'', qui est mis à 1 après une première écriture. Si ce bit est à 0, alors pas besoin de recopier la ligne de cache : elle n'a pas été modifiée, la RAM a déjà la bonne copie. Mais s'il est à 1, le cache et la RAM n'ont pas le même contenu, la recopie s'exécute. Les '''instructions d'invalidation''' permettent d'invalider une ligne de cache, à savoir d'effacer son contenu. Nous verrons à quoi servent ces instructions dans la section sur les changement de processus. Invalider une ligne de cache est une opération optimisée : le cache n'est en réalité pas réellement effacé. À la place, le bit ''Valid'' de chaque ligne de cache est juste mis à 0. Il faut noter que l'invalidation efface les lignes de cache sans se préoccuper de leur contenu. Elle se moque qu'une ligne de cache contienne une donnée modifiée, ''dirty'' ou quoique ce soit : la ligne de cache est effacée, point. Il est possible d'invalider une ligne de cache en fournissant une adresse mémoire, mais il est aussi possible d'invalider le cache tout entier. Le choix entre les deux dépend du mode d'adressage de l'instruction d'invalidation. Parfois, il existe une instruction séparée pour invalider tout le cache, et une autre pour invalider une ligne de cache bien précise. Des instructions séparées sont parfois disponibles pour invalider les caches de données et d'instructions, parfois aussi la TLB (un cache qu'on verra dans quelques chapitres). Il est possible de n'invalider que le cache L1, voire le cache L2. Il faut noter que l'invalidation efface tout le cache, mais ne se préoccupe pas de vérifier si les données ont été modifiées dans le cache. Pour certains caches, comme le cache d'instruction, ce n'est pas un problème, vu qu'il est en "lecture seule". Mais pour les caches de données, les données modifiées sont perdues en cas d'invalidation. Heureusement, il existe des instructions d'invalidation qui fusionnent une instruction ''clean'' et une instruction d'invalidation. Il s'agit d''''instructions d'invalidation spéciales'''. ===Les instructions d'optimisation : instructions non-temporelles et écritures optimisées=== Les '''instructions mémoire non-temporelles''' contournent complètement le cache. Par exemple, une lecture peut lire une donnée, mais celle-ci ne sera pas chargée dans le cache, elle passe directement de la RAM vers les registres. Une section entière de ce chapitre sera dédiée au contournement du cache, à savoir aux situations où les accès mémoire doivent passer directement du processeur à la RAM sans passer par le cache. D'autres instructions assez rares incorporent des indications pour le cache. Par exemple, l'instruction ''load last'' des processeurs POWER PC implique que la donnée ne sera utilisée qu'une seule fois. Elle est donc chargée dans le cache, mais la ligne de cache est configurée de manière à être remplacée très rapidement, typiquement avec une valeur de LRU/LFU adéquate. La donnée est bien chargée dans le cache, au cas où elle doive être relue suite à une mauvaise prédiction de branchement ou autre, chose qu'une lecture non-temporelle (qui contourne le cache) ne fait pas. Des indications de ce type sont appelées des '''''cache hint'''''. L''''instruction ''flush''''' permet de préciser qu'une ligne de cache contient une donnée inutile, qui ne sera pas réutilisée par le programme. Pas besoin de la conserver dans le cache, elle peut laisser sa place à des données plus utiles. Or, sans indication, les algorithmes de remplacement d'une ligne de cache risquent de conserver cette donnée trop longtemps, ce qui entraine une certaine pollution du cache par des données inutiles. Une autre instruction est elle beaucoup plus importante : celle de '''pré-allocation sur écriture'''. Elle sert dans le cas où une ligne de cache est complétement écrite. Par exemple, imaginons qu'on veuille écrire dans une portion de mémoire. Si celle-ci n'est pas dans le cache, le processeur va charger une ligne de cache complète depuis la RAM, écrire dans la ligne de cache, puis recopier la ligne de cache modifiée en mémoire RAM. Une écriture en RAM demande donc de faire une lecture et une écriture. Mais les instructions de pré-allocation sur écriture permettent de prévenir qu'une ligne de cache sera intégralement écrite, et qu'il n'y a donc pas besoin de lire celle-ci depuis la RAM. Notons que l'instruction d'écriture qui suit n'est pas une écriture non-temporelle, vu que les données sont écrites dans la ligne de cache, qui est ensuite envoyée en mémoire RAM dès que nécessaire. De plus, les données écrites peuvent ensuite être relue depuis le cache si nécessaire. Enfin, certains processeurs MIPS incorporent une instruction pour modifier le tag d'une ligne de cache. Elles servent à optimiser les copies mémoire, à savoir quand on copie un bloc de données d'un endroit à un autre. L'idée est de charger le bloc de données dans le cache avec une instruction LOAD/PREFETCH, de modifier le tag pour qu'il pointe vers l'adresse à écrire, et de laisser faire le cache pour que l'écriture se fasse en RAM. Mais les contraintes pour utiliser cette instruction sont assez drastiques : les données doivent être alignées sur la taille d'une ligne de cache, le bloc de départ et d'arrivée (l'original versus la copie) ne doivent pas se recouvrir, etc. ==L'associativité des caches et leur adressage implicite== Lorsqu'on souhaite accéder au cache, il faut trouver quelle est la ligne de cache dont le tag correspond à l'adresse demandée. On peut classifier les caches selon leur stratégie de recherche de la ligne correspondante en trois types de caches : totalement associatifs, directement adressés (''direct mapped'') et associatifs par voie. ===Les caches totalement associatifs=== Avec les caches totalement associatifs, toute donnée chargée depuis la mémoire peut être placée dans n'importe quelle ligne de cache, sans aucune restriction. Ces caches ont un taux de succès très élevé, quand on les compare aux autres caches. [[File:Cache totalement associatif.png|centre|vignette|upright=2|Cache totalement associatif.]] Concevoir un cache totalement associatif peut se faire de deux grandes manières différentes. La première consiste tout simplement à combiner une mémoire associative avec une mémoire RAM, en ajoutant éventuellement quelques circuits annexes. La mémoire associative mémorise les tags, alors que la mémoire RAM mémorise les données de la ligne de cache, éventuellement avec quelques bits de contrôle. La ligne de cache est stockée à une adresse A dans la mémoire RAM et son tag est stocké à la même adresse, mais dans la mémoire CAM. Ce faisant, quand on envoie le tag à la mémoire CAM, elle renvoie l'adresse de la ligne de cache dans la mémoire RAM. Cette adresse est alors envoyée directement sur le bus d'adresse de la RAM, et la lecture est effectuée automatiquement. Il faut ajouter quelques circuits annexes pour garantir que les écritures se passent correctement dans les deux mémoires, mais rien de bien terrible. [[File:Cache fabriqué avec une mémoire associative et une RAM.png|centre|vignette|upright=3|Cache fabriqué avec une mémoire associative et une RAM]] Il est cependant possible d'optimiser un tel cache, en fusionnant la mémoire CAM et la mémoire RAM, afin d'éliminer des circuits redondants. Pour comprendre pourquoi, rappelons que les mémoires CAM sont composées d'un plan mémoire, d'un paquet de comparateurs et d'un encodeur. Quant à la mémoire RAM, elle est composée d'un décodeur connecté au plan mémoire. En mettant une CAM suivie d'une RAM, on a un encodeur dont l'entrée est envoyée à un décodeur. [[File:Cache totalement associatif naif.png|centre|vignette|upright=3|Cache totalement associatif naif]] Or, le décodeur réalise l'opération inverse de l'encodeur, ce qui fait que mettre les deux composants à la suite ne sert à rien. On peut donc retirer l'encodeur et le décodeur, et envoyer directement les résultats des comparateurs sur les entrées de commande du plan mémoire de la RAM. [[File:Cache totalement associatif optimisé.png|centre|vignette|upright=2|Cache totalement associatif optimisé]] Avec cette méthode, les circuits du cache ressemblent à ce qui illustré ci-dessous. Le tag est envoyé à chaque ligne de cache. Le tag envoyé est alors comparé avec le Tag contenu dans chaque ligne de cache, comme c'est le cas sur les mémoires associatives. Si une ligne de cache matche avec le tag envoyé en entrée, la ligne pour laquelle il y a eu une égalité est alors connectée sur les lignes de bit (''bitlines''). Cela est réalisé par un circuit commandé par le comparateur de la ligne de cache. Il ne reste plus qu'à sélectionner la portion de la ligne de cache qui nous intéresse, grâce à un paquet de multiplexeurs. Cela permet d'effectuer une lecture ou écriture, mais il faut aussi préciser si il y a eu un défaut de cache ou un succès. Un succès de cache a lieu quand au moins des comparaisons est positive, alors que c'est un défaut de cache sinon. En clair, détecter un succès de cache demande juste de connecter une porte OU à plusieurs entrées à tous les comparateurs. [[File:Organisation générale d'un cache totalement associatif.png|centre|vignette|upright=2|Organisation générale d'un cache totalement associatif.]] ===Les caches directement adressés=== Les caches directement adressés peuvent être vus comme un cache totalement associatif auquel on aurait ajouté des restrictions assez drastiques. Plus haut, on a vu qu'un cache totalement adressé est équivalent à la combinaison d'une CAM avec une RAM. La mémoire CAM prend en entrée un Tag et traduit celui-ci en une adresse qui commande la mémoire RAM interne au cache. Dans ce qui suit, l'adresse interne au cache sera appelé l''''indice''' pour éviter toute confusion. [[File:Cache hash table - 2.png|centre|vignette|upright=2|Fonctionnement interne du cache, expliquée sous forme abstraite, en utilisant la notion d'indice interne au cache.]] Les caches directement adressés cherchent à remplacer la mémoire CAM par un circuit combinatoire. Ce circuit traduit le Tag en indice, mais est beaucoup plus simple qu'une mémoire CAM. Mais qui dit circuit plus simple dit circuit plus limité. Un circuit combinatoire n'est pas aussi versatile que ce qui est permis avec une mémoire CAM. En conséquence, une restriction majeure apparait : toute adresse mémoire est associée dans une ligne de cache prédéfinie, toujours la même. L'association entre ligne de cache et adresse mémoire est faite par le circuit combinatoire, et ne peut pas changer. Les concepteurs de caches s'arrangent pour que des adresses consécutives en mémoire RAM occupent des lignes de cache consécutives, par souci de simplicité. Tout se passe comme suit la mémoire RAM était découpés en blocs de la même taille que le cache. La première adresse du bloc est associée à la première ligne de cache (celle d'indice 0), la seconde adresse est associée à la seconde adresse du_ bloc, et ainsi de suite. Le tout est illustré ci-dessous. [[File:Cache adressé directement.png|centre|vignette|upright=2|Cache adressé directement.]] Avec cette contrainte, le circuit de traduction de l'adresse en adresse mémoire pour la RAM interne au cache est drastiquement simplifié, et disparait même. Une partie de l'adresse mémoire sert à indiquer la position de la donnée dans le cache, le reste de l'adresse sert encode le tag et la position de la donnée dans le ligne de cache. [[File:Cache line.png|centre|vignette|upright=2|Adresse d'une ligne de cache sur un cache adressé directement.]] Un cache directement adressé est conçu avec une RAM, un comparateur, et un paquet de multiplexeurs. En général, la mémoire RAM stocke les lignes de caches complète. Il arrive que l'on utilise deux mémoires RAM : une pour les tags et une pour les données, mais cette technique augmente le nombre de circuits et de portes logiques nécessaires, ce qui réduit la capacité du cache. L'index à lire/écrire est envoyé sur l'entrée d'adresse de la RAM, la RAM réagit en mettant la ligne de cache sur sa sortie de donnée. Sur cette sortie, un comparateur compare le tag de la ligne de cache lue avec le tag de l'adresse à lire ou écrire. On saura alors si on doit faire face à un défaut de cache. Ensuite, un multiplexeur récupère la donnée à lire/écrire. [[File:Direct mapped cache - french.png|centre|vignette|upright=2|Cache directement adressé.]] L'accès à un cache directement adressé a l'avantage d'être très rapide vu qu'il suffit de vérifier une seule ligne de cache : celle prédéfinie. Mais ces caches ne sont cependant pas sans défauts. Vu que le cache est plus petit que la mémoire, certaines adresses mémoires se partagent la même ligne de cache. Si le processeur a besoin d’accéder fréquemment à ces adresses, chaque accès à une adresse supprimera l'autre du cache : tout accès à l'ancienne adresse se soldera par un défaut de cache. Ce genre de défauts de cache causés par le fait que deux adresses mémoires ne peuvent utiliser la même ligne de cache s'appelle un '''défaut par conflit''' (''conflict miss''). Les défauts par conflit n'existent pas sur les caches totalement associatifs. En conséquence, le taux de succès des caches directement adressés est assez faible comparé aux autres caches. [[File:Cache Block Basic Conflict.svg|centre|vignette|upright=1.5|Exemple de ''Conflict Miss''.]] ===Les caches associatifs par voie=== Les caches associatifs par voie sont un compromis entre les caches directement adressés et les caches totalement associatifs. Pour simplifier, ces caches sont composés de plusieurs caches directement adressés accessibles en parallèle, chaque cache/RAM étant appelé une '''voie'''. Avec ces caches, toute adresse mémoire en RAM est associée à une ligne de cache dans chaque voie. [[File:Cache associatif par voie.png|centre|vignette|upright=2|Cache associatif par voie.]] Le schéma ci-dessous compare un cache directement adressé et un cache associatif à deux voies. On voit que chaque adresse est associée à une ligne de cache bien précise avec un cache directement dressé, et à deux lignes de cache avec un cache associatif à deux voies. L'adresse sera associée à 4 lignes de cache sur un cache associatif à 4 voies, à 8 lignes pour un cache à 8 voies, etc. L'ensemble des lignes de cache associées à une adresse est appelé un '''ensemble'''. [[File:Cache Fill.svg|centre|vignette|upright=2|Comparaison entre un cache directement adressé et un cache associatif à deux voies.]] Sur ces caches, toute adresse est découpée en trois parties : un tag, un index, et un décalage, comme sur les caches directement adressés. Comme vous pouvez le voir, l'organisation est identique à celle d'un cache totalement associatif, à part que chaque ensemble tag-ligne de cache est remplacé par une mémoire RAM qui en contient plusieurs. [[File:Implémentation d'un cache associatif par voie.png|centre|vignette|upright=2|Implémentation d'un cache associatif par voie.]] Le risque de conflits d'accès au cache est donc réduit sur un cache associatif à plusieurs voies, et il est d'autant plus réduit que le cache a de voies. Par contre, leur conception interne fait qu'ils ont un temps d'accès légèrement élevé que les caches directement adressés. Les caches associatifs par voie ont donc un taux de succès et un temps d'accès intermédiaire, situé entre les caches directement adressés et totalement associatifs. Ils sont une sorte de compromis entre réduction des défaut par conflits d'accès au cache et temps d'accès, et complexité des circuits. ==Les optimisations des caches associatifs par voie== Les caches partiellement associatifs regroupent les caches associatifs par voie et directement adressés, ainsi que leurs variantes. En clair : tous les caches qui ne sont pas totalement associatifs. Ils peuvent être optimisés de nombreuses manières, que ce soit pour gagner en performance ou pour économiser de l’énergie. Dans cette section, nous allons voir quelles sont ces optimisations. ===Les caches pseudo-associatifs=== Les caches adressés par voie contiennent une mémoire SRAM par voie. En théorie, les voies sont accédées en parallèles, en même temps, afin de voir si l'on a un succès de cache ou un défaut. Les '''caches pseudo-associatifs''' sont identiques aux caches associatifs par voie, si ce n'est qu'ils vérifient chaque voie une par une. Ils ont été utilisés sur des processeurs commerciaux, un exemple étant l'IBM 370. Là encore, on perd en performance pour gagner en consommation d'énergie. Le temps d'accès dans le meilleur des cas est plus faible pour les caches pseudo-associatifs, mais le pire des cas teste tous les caches avant de tomber sur le bon. Les performances sont donc réduites. Mais la consommation énergétique est meilleure, vu qu'on ne vérifie pas forcément toutes les voies en parallèle. On teste la première voie, éventuellement la seconde, peut-être la troisième, etc. Mais dans le cas général, on ne teste qu'une partie des voies, pas toutes, ce qui donne un gain en termes d'énergie. L'implémentation de caches de ce genre demande que l'on parcoure les voies une par une, en commençant de la première jusqu'à la dernière. Pour cela, un simple compteur suffit. Suivant la valeur du compteur, la voie associée est activée puis accédée. Toute la complexité revient à ajouter un circuit qui prend la valeur du compteur, et active la voie associée, lance un accès mémoire dessus. Vu que les voies sont chacune des caches ''direct mapped'', il suffit pour cela de geler les entrées d'adresse, soit en les déconnectant, soit en utilisant du ''clock gating'' ou de l'évaluation gardée. Les détails d'implémentation, non-cités ici, varient selon le cache. ===La prédiction de voie=== Pour réduire le temps d'accès des caches pseudo-associatifs, certains chercheurs ont inventé la '''prédiction de voie''', qui consiste à faire des paris sur la prochaine voie accédée. L'idée est d'accéder à la voie qui contient la donnée voulue du premier coup, en lisant celle-ci en priorité. Dans son implémentation la plus simple, le cache reste un cache pseudo-associatif. Lors d'un accès au cache, les voies sont toutes parcoures une par une. Par contre, les voies ne sont donc pas parcourues de la première vers la dernière, mais dans un ordre différent. Cette technique permet de mettre en veille les voies sur lesquels le processeur n'a pas parié, ce qui permet de diminuer la consommation énergétique du processeur. C'est plus efficace que d'aller lire plusieurs données dans des voies différentes et de n'en garder qu'une. L'implémentation est assez simple : il suffit d'ajouter un circuit de prédiction de voie,relié au compteur de voie. Une amélioration de la technique fait fonctionner le cache comme un intermédiaire entre cache pseudo-associatif et associatif par voies. L'idée est de chercher la voie prédite en premier, puis de chercher dans toutes les voies en parallèle en cas de défaut de cache. Au lieu d'attendre que les comparaisons de tags donnent leur résultat, le processeur sélectionne automatiquement une voie et configure les multiplexeurs à l'avance. Si le processeur ne se trompe pas, le processeur accède à la donnée plus tôt que prévu. S'il se trompe, le processeur annule la lecture effectuée en avance et recommence en faisant un accès en parallèle aux autres voies. Le compromis entre performance et consommation d'énergie est alors différent. On économise de l'énergie par rapport à un cache associatif par voie, au prix d'une petite perte de performance (doublement des temps d'accès). Mais par rapport à un cache pseudo-associatif, l'économie d'énergie est bien moindre, au prix d'un gain en performance assez manifeste. Prédire quelle voie sera la bonne est assez simple. En vertu du principe de localité, les accès futurs ont des chances de tomber dans les voies les plus fréquemment utilisées ou dans celle plus récemment utilisée. Il suffit de retenir la voie la plus récemment accédée dans un registre, qui sera utilisée comme prédiction. Pour vérifier que la prédiction est correcte, il suffit de comparer le registre et le résultat obtenu après vérification des tags. Cependant, on peut complexifier l'implémentation pour prendre en compte l'adresse à lire/écrire, l'instruction à l'origine de l'accès mémoire ou tout autre paramètre utile. Par exemple, des instructions différentes ont tendance à aller chercher leurs données dans des ensembles différents et la voie à choisir n'est pas la même. Pour cela, il suffit d'utiliser un cache pour stocker la correspondance instruction - voie. Pour plus de simplicité, la mémoire cache des prédictions est parfois remplacée par une RAM, qui est adressée : * soit par le program counter de l'instruction à l'origine de l'accès (en réalité, seulement quelques bits de poids faible de l'adresse) ; * soit par l'adresse à accéder (là encore, quelques bits de poids faible) ; * soit (pour les modes d'adressage qui utilisent un registre de base et un décalage) par un XOR entre les bits de poids faible de l'adresse de base et le décalage ; * soit par autre chose. ===La mise en veille sélective des voies=== Les caches associatifs ont tendance à utiliser beaucoup d'énergie, même quand on n'y accède pas. Aussi, certains processeurs détectent quand le cache est peu utilisé et en profitent pour mettre en veille les voies inutilisées. Vous vous demandez certainement ce qui se passe quand une donnée à lire/écrire est dans une voie désactivée. La réponse est que le cache détecte cette situation, car elle déclenche un succès de cache. Les ''tags'' ne sont en effet pas désactivés, seules les données sont mises en veille. L'implémentation est plus simple sur les caches qui séparent les tags et les données dans deux RAM différentes. Cette optimisation marche surtout sur les gros caches, qui ont des chances d'avoir une portion significative d’inutilisée (pas assez de données pour les remplir), donc généralement les caches L3/L4. Par exemple, les processeurs d'Intel de microarchitecture Ivy Bridge disposent d'un cache de 8 mébioctets à 16 voies, qu'ils peuvent faire passer à 512 kibioctets si le besoin s'en fait sentir. Quand ces processeurs détectent une faible activité, ils mettent en veille 14 voies et n'en gardent que 2 d'actives. Évidemment, les 14 voies sont vidées avant d'être mises en veille, afin qu'une aucune donnée ne soit perdue. ===Les caches ''skew-associative''=== Vous aurez remarqué que dans une voie, les lignes sont accédées en adressage direct : les défauts par conflit sont possibles sur un cache associatif par voie. Pour éviter cela, certains chercheurs ont créé des '''caches ''skew associative''''' (ou associatifs à biais). Pour faire simple, les index des lignes de cache subissent un petit traitement avant d'être utilisés. Le traitement en question est différent suivant la voie de destination, histoire que deux adresses mémoires avec des index identiques donnent des index différents après traitement. Le traitement en question est souvent une permutation des bits de l'index, qui est différente suivant la voie prise, ou un simple XOR avec un nombre qui dépend de la voie. [[File:Implémentation d'un cache skew associative.jpg|centre|vignette|upright=2|Implémentation d'un cache skew associative.]] ==Les caches splittés (''phased caches'')== Dans cette section, nous allons voir les '''caches splittés''' (''phased caches''), qui sont une variante des caches ''direct-mapped'', dans lequel le cache est accédé en deux étapes consécutives. Il ne s'agit pas des caches pipelinés, que nous verrons dans le chapitre sur les processeurs pipélinés, mais laissons cela à plus tard. Il est possible d'appliquer la même méthode sur un cache associatif par voie, mais il y a des méthodes plus simples, qui permettent là aussi d’accéder au cache en plusieurs étapes consécutives. L'idée est de scinder le cache en deux : une mémoire pour les tags, une autre pour les données de la ligne de cache. Les bits de contrôle peuvent être mis dans l'une ou l'autre SRAM, mais ils sont souvent mis dans la RAM pour les tags. En faisant cela, quelques optimisations deviennent possibles, afin de réduire la consommation énergétique en contrepartie d'une perte de performance. La technique s'implémente différemment pour les caches totalement associatifs et partiellement associatifs. Les caches totalement associatifs splittés sont ceux formés en combinant un cache associatif avec une CAM et une RAM combinée. On envoie l'adresse à lire/écrire à la mémoire associative, elle répond en envoyant une adresse à la mémoire RAM. L'accès se fait donc en deux temps, avec l'adresse dans la RAM comme intermédiaire. Il est possible de séparer physiquement les deux étapes en insérant un registre entre la CAM et la RAM, ce qui permet aussi de pipeliner l'accès. Mais c'est rarement fait en pratique, car le cout en circuit d'une mémoire CAM est trop important. L'équivalent pour un cache totalement associatif optimisé, sans CAM et RAM séparée, est trop gourmande en interconnexions pour être implémentée. Les caches totalement associatifs splittés sont donc très rares, l'auteur ne connait aucun exemple de processeur avec un tel cache. Il existe une technique équivalente pour les caches ''direct-mapped'', mais elle demande une certaine modification du cache. Dans les caches ''direct-mapped'' non-splittés, on trouve une mémoire SRAM dont chaque mot mémoire contient une ligne de cache entière, tag inclus. Dans leurs versions splittés, la SRAM est séparée en deux : une pour les tags, une autre pour les données. Précisons qu'il s'agit bien de deux mémoires SRAM adressables. L'adresse à laquelle accéder est envoyée à la SRAM des tags, puis ensuite à la SRAM des données si besoin. L'idée est d’accéder aux tags pour déterminer s'il y a un succès de cache ou un défaut, et ensuite d'accéder aux données. On n’accède pas aux données en parallèle des tags. Faire cela est évidemment plus lent. En cas de défaut de cache, le temps d'accès est similaire : le tag ne correspond pas, on n'accède pas à la SRAM pour les données. Par contre, vu qu'on n'a pas activé la SRAM pour les données, on économise un peu d'énergie, ce qui réduit la consommation d'énergie. En cas de succès de cache, on accède à la SRAM pour les tags, puis à celle pour les données. Pas d'économie d'énergie à l'horizon, sans compter que le temps d'accès augmente : on accède au cache en deux étapes au lieu de faire les deux accès en parallèle. [[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]] Précisons cependant que ce design peut avoir deux avantages en termes de performance. Premièrement, le temps d'accès au cache est légèrement amélioré en cas de défaut de cache. En effet, la SRAM des tags est assez petite, idem pour celle des données. Leur temps d'accès est donc plus faible que pour une grosse SRAM contenant données et tags. Le gain en temps d'accès est donc un avantage, qui ne se manifeste surtout en cas de défaut de cache. Un autre avantage est que l'accès au cache se pipeline plus facilement, ce qui fait qu'on peut effectuer plusieurs accès simultanés au cache. Mais nous verrons cela dans quelques chapitres. ===Le contrôleur de cache 82385 pour les CPU Intel 386=== Il est important de noter que la séparation entre tags et RAM peut être telle que les deux ne sont pas sur la même puce de silicium ! Voire que les deux sont séparés du processeur ! C'était le cas quand les mémoires caches ont été introduites sur les processeurs grand public, notamment sur les premiers processeurs Intel. La miniaturisation n'avait pas avancé au point où placer un cache dans le processeur était possible. Sur le processeur 386 d'Intel, le cache était un cache splitté, séparé du processeur. Concrètement, le processeur i386 était couplé à un contrôleur de cache Intel 82385 et une mémoire SRAM. Le 82385 contenait les ''tags'' et les bits de contrôle, la SRAM contenait les données, les lignes de cache. Un point important est que les lignes de cache faisaient seulement 32 bits/4 octets, pas plus ! On était loin des lignes de cache actuelles, faisant 64 octets/512 bits. Mais c'était beaucoup plus pratique, vu que le bus système faisait 32 bits de large, idem pour l'interface avec le processeur. Pour intégrer un cache facultatifs (certains ordinateurs 'en passaient). Le schéma ci-dessous montre comment le cache s'intégrait avec le bus système. Pour le bus de commande, le cache servait d'intermédiaire : il recevait les commandes et et les filtrait suivant les succès/défauts de cache. En cas de succès de cache, les commandes de lecture n'étaient pas envoyées à la mémoire RAM. Les adresses étaient transmises à la fois au cache et au bus système (avec un registre entre le bus système et le processeur). Le bus de donnée était lui connecté à la mémoire SRAM et au processeur, avec des MUX/DEMUX pour faire le choix de la source des lectures. [[File:Controleur de cache 82385 pour l'Intel 386.png|centre|vignette|upright=2.5|Contrôleur de cache 82385 pour l'Intel 386]] Le 82385 surveillait ce qui se passait sur le bus et répondait à la place de la RAM pour certaines lectures. C'était un intermédiaire assez passif, qui se contenait de répondre aux succès et défauts en lecture. Le cache était un cache ''write through'' un peu particulier. En cas de succès de cache pour une écriture, le cache met à jour sa ligne de cache et propage l'écriture en mémoire RAM. Par contre, si une écriture fait un défaut de cache, la donnée n'est pas écrite dans le cache. Le seul moyen pour copier une donnée dans le cache était un défaut pour une lecture. Le 82385 pouvait commander soit un cache ''direct mapped'', soit associatif à deux voies. Le choix entre les deux était le fait d'une entrée : la mettre à 0 indiquait un cache ''direct mapped'', la mettre à 1 forçait un cache à deux voies. La différence entre les deux est que le 82385 était relié à une mémoire SRAM avec un cache ''direct mapped'', deux SRAM pour deux voies. Pour avoir un cache associatif à deux voies, le 82385 devrait gérer deux signaux ''chip select'' pour activer chaque SRAM/voie suivant les besoins. Il avait précisément quatre signaux CS : deux par SRAM, un pour les lectures, un pour les écritures. Notons que les lignes de cache faisaient 32 bits, ce qui pouvait d'obtenir soit avec une SRAM 32 bits, soit avec deux SRAM 16 bits, soit avec 4 SRAM 8 bits. Le 82385 rajoutait 4 sorties, pour masquer chaque octet dans ces 32 bits, qui sont techniquement des signaux ''Chip Select'' pour 4 SRAM 8 bits. [[File:Interface entre le 82385 et la SRAM du cache.png|centre|vignette|upright=2|Interface entre le 82385 et la SRAM du cache. Beaucoup d'entrées et de sorties liées au bus d'adresse ne sont pas représentées.]] Il gérait aussi les accès mémoire non-cacheable, à savoir des accès mémoire qui ne doivent pas être pris en compte par le cache. Il considérait certains accès mémoire comme "à ne pas cacher". Notamment, les accès mémoire à une entré-esortie ne sont pas cachés. Pour rappel, le processeur utilisait un espace d'adressage séparé pour les entrées-sorties, et utilisait donc un bit IO, qui était utilisé par le 82385 pour savoir si l'accès mémoire doit être caché ou non. Il en est de même pour les accès ayant lieu lors d'une interruption, qui ne passent pas par le cache. Mais au-delà de cette inhibition automatique du cache, le 82385 avait une entrée NCA (''Non Cacheable Access'') : le cache était "désactivé" quand cette entrée était à 1. C'est un peu une sorte de ''chip select'' pour le 82385, limitée aux accès mémoire. Cette entrée permettait de programmer des intervalles d'adresse auxquels ne pas répondre, en utilisant des circuits de décodage d'adresse adaptés. Il avait aussi une entrée X16, qui permettait d'identifier les accès soit à un composant 16 bits. De tels accès ne doivent pas être mis en cache, sans doute parce que cela ne collait pas avec la taille des lignes de cache (32 bits). Et cette entrée permettait d'inhiber ces accès 16 bits d'agir sur le cache, en utilisant le bit du bus de commande adéquat. Le 82385 pouvait être intégré dans un système à deux processeurs, voire plus. Pour cela, chaque processeur avait son propre 82385 et sa SRAM rien qu'à lui. Il n'y avait pas de cache partagé entre les deux processeurs. Par contre, les deux caches étaient reliés au même bus système. Pour qu'ils ne se marchent pas sur les pieds, il y avait des circuits d'arbitrage pour gérer l'accès au bus. Un des deux 82385 était mis en mode maitre, l'autre était en mode esclave. Le 82385 maitre pouvait prendre le contrôle du bus, le 82385 esclave devait demander l'autorisation au premier pour accéder au bus système. Le 82385 gérait une forme limitée de cohérence des caches par invalidation. Dès que le 82385 détectait une prise de contrôle du bus par autre chose que le processeur, il surveillait les adresses transmises sur le bus. En cas de succès de cache, la ligne de cache associée était invalidée. Au-delà de ca, le 82385 avait une entrée FLUSH, qui ordonnait une invalidation totale du cache. Si cette entrée est mise à 1, toutes les lignes de cache sont invalidées. Les ''tags'' sont marqués comme invalides, mais les lignes de cache elles-mêmes ne sont pas touchées. ===L'exemple des processeurs Intel de microarchitecture ''Broadwell''=== Un autre exemple est celui du cache L4 des processeurs Broadwell et de quelques processeurs séparés. Ces processeurs ont une organisation en ''chiplet'' où le processeur incorpore plusieurs puces séparées : une puce pour le processeur proprement dit, une puce nommée ''Crystal Well'' pour le cache L4, et une puce IO pour la communication avec la RAM et la carte mère. Le processeur incorporait un cache L4 de 128 mébioctets, composé de mémoire eDRAM, qui était dispersé entre ''Crystal Well'' et les autres puces. Les données du cache L4 étaient dans ''Crystal Well'', alors que les Tags étaient soit dans le processeur lui-même, soit dans la puce IO ! La puce ''Crystal Well'' était une mémoire DRAM adressable tout ce qu'il y a de plus basique, avec cependant quelques optimisations notables. Par exemple, elle avait deux bus séparés pour l'écriture et la lecture. De plus, elle avait une organisation interne avec 128 banques, contre moins d'une dizaine pour la DDR de l'époque et environ 32 banques pour la DDR5 moderne. Elle contenait aussi quelques circuits pour gérer son rôle de mémoire cache, mais rien en ce qui concerne la gestion des tags eux-mêmes. Sur les processeurs de microarchitecture ''Broadwell'', les tags étaient placés dans le CPU et précisément dans le cache L3. À chaque accès mémoire au cache L3, les tags du cache L4 étaient consultés en parallèle. De fait, l'accès au cache L4 était assez rapide, malgré le fait que les données étaient dans une puce à part. Ajoutons à cela que le processeur et ''Crystal Well'' n'avaient pas la même finesse de gravure ni la même technologie de fabrication. Les tags étaient implémentés avec de la SRAM contre la DRAM pour les données, ce qui fait que la consultation des tags était plus rapide que l'accès aux données. Par la suite, dans certains CPU de microarchitecture ''skylake'', les tags ont été déplacés en-dehors du processeur pour finir dans le contrôleur mémoire. En faisant cela, le cache L4 pouvait être utilisé par autre chose que le processeur, et notamment par la carte graphique intégrée au CPU. Avec ''broadwell'', le fait que les tags étaient consultés en cas d'accès au L3 empêchait au GPU intégré de consulter le cache L4. Mais en déplaçant les tags dans le contrôleur mémoire, ce n'est plus le cas vu que la carte graphique a aussi accès au bus mémoire. Par contre, le temps d'accès augmente comparé à la solution précédente. On n'accède pas aux tags du L4 en parallèle du L3 : à la place, il faut consulter les tags du L3, détecter un défaut de cache L3, et ensuite accèder aux tags. ===Les caches RAM-configurables=== Un autre avantage des caches splittés est qu'on peut les modifier pour servir à la fois de mémoire cache, mais aussi de ''local store'', de mémoire RAM de petite taille. Le fonctionnement est assez simple à comprendre. Lors d'un accès au cache, on accède aux tags, puis à la RAM interne au cache. Lors d'un accès au ''local store'', on contourne l'accès au tags et on accède à la RAM interne au cache directement. Il s'agit de la technique du '''cache RAM-configurable''. L'usage de cache RAM-configurable est fréquent sur les cartes graphiques récentes, qui incorporent un ou plusieurs processeurs multicoeurs, dont le cache L1 de données est un cache RAM-configurable. [[File:Hydride cache - local store.png|centre|vignette|upright=2.0|Hydride cache - local store]] ===La compression de cache=== Une autre optimisation permise par les ''phased caches'' est l'implémentation de techniques de '''compression de cache''', qui visent à compresser des lignes de cache. L'intérêt est qu'on peut stocker plus de données dans le cache, à capacité égale. L'inconvénient est qu'on doit compresser/décompresser les lignes de cache, ce qui demande un circuit en plus et allonge les temps d'accès. En effet, le temps mis pour compresser/décompresser une ligne de cache s'ajoute au temps d'accès. Aussi, la compression de cache sert surtout pour les caches de bas niveau dans la hiérarchie mémoire, les gros caches aux temps d'accès assez longs. Une première technique, assez simple à implémenter et peu couteuse en circuit, est celle de la '''compression des lignes de cache nulles'''. Elle compresse uniquement les lignes de cache qui ne contiennent que des zéros. L'idée est qu'on ajoute, dans la mémoire des tags, un bit de contrôle pour chaque ligne de cache appelé le bit ''null''. Il indique si la ligne de cache ne contient que des zéros. Quand on lit une ligne de cache, la mémoire des tags est accédée et on vérifie le bit ''null'' : s'il vaut 1, on n'accède pas à la mémoire cache de données et un multiplexeur envoie un zéro sur le port de lecture. Le bit ''null'' est fixé lors de l'écriture d'une ligne de cache : elle passe dans un comparateur avec zéro relié à la mémoire des tags. La comparaison avec zéro peut se faire en parallèle de l'écriture ou avant (dans ce cas, on n'écrit pas la ligne de cache dans le cache). Les autres techniques de compression de cache permettent de compresser autre chose que des lignes de cache nulles. L'idée est qu'une ligne de cache physique peut par moment mémoriser plusieurs lignes de caches compressées. Par exemple, prenons un cache dont les lignes de cache font 64 octets. Il est possible de compresser deux lignes de cache pour qu'elles fassent chacune 32 octets, et les stocker dans une seule ligne de cache. Les deux lignes de cache auront des tags différents, mais pointeront sur la même ligne de cache physique. Et cela demande d'utiliser un ''phased cache'' dont la mémoire pour les tags est plus grande que la mémoire pour les données. Il n'y a donc plus une bijection entre tags et ligne de cache, mais une relation surjective. Chose qui n'est possible qu'avec un ''phased cache''. De plus, des bits de contrôles associés à chaque ''tag'' indiquent où se trouvent les lignes de cache compressées dans la ligne de cache : est-ce que c'est les 32 octets de poids fort ou de poids faible ? [[File:Compression de cache.png|centre|vignette|upright=2|Compression de cache]] Il ne semble pas que les techniques de compression de cache soient implémentées sur les processeurs modernes. Aucun n'utilise de compression de cache, à ma connaissance. Il faut dire que les techniques connues sont de mauvais compromis : le temps d'accès du cache augmente beaucoup, le cout en circuit pourrait être utilisé pour un cache non-compressé mais plus grand. Et notons que la compression de cache ne marche que si les données peuvent se compresser. Si ce n'est pas le cas, une partie de la mémoire des tags est inutilisée. Une revue de la littérature académique sur la compression de cache est disponible via ce lien, pour les curieux : * [https://inria.hal.science/hal-03285041 Understanding Cache Compression, par Carvalho et Seznec]. ==L'adressage physique ou logique des caches== Le cache utilise les adresses à lire/écrire pour déterminer s'il a une copie de la donnée en son sein. Mais l’interaction entre caches et mémoire virtuelle donne lieu à un petit problème : l'adresse utilisée est-elle une adresse virtuelle/logique ou physique ? La réponse varie suivant le processeur : certains caches utilisent l'adresse virtuelle, tandis que d'autres prennent l'adresse physique. On parle de cache '''virtuellement tagué''' dans le premier cas et de cache '''physiquement tagué''' dans le second. {| |[[File:Cache tagué virtuellement.png|vignette|Cache tagué virtuellement.]] |[[File:Cache tagué physiquement.png|vignette|Cache tagué physiquement.]] |} ===L'accès à un cache physiquement/virtuellement tagué=== La manière d'accéder à un cache dépend de s'il est virtuellement ou physiquement tagué. Il faut utiliser l'adresse virtuelle pour les premiers, physique pour les seconds. Avec un cache virtuellement tagué, l'adresse logique peut être envoyée directement au cache. La MMU ne traduit les adresses que s'il faut accéder à la mémoire RAM. Ces caches sont donc plus rapides. Avec un cache physiquement tagué, le processeur doit traduire l'adresse logique en adresse physique dans la MMU, avant d'accéder au cache. La traduction d'adresse se fait soit en accédant à une table des pages en mémoire RAM, soit en accédant à un cache spécifiquement dédié à accélérer la traduction d'adresse, la TLB (''Translation Lookaside Buffer''). Dans la quasi-totalité des cas, la traduction d'adresse passe par la TLB, ce qui fait qu'elle est raisonnablement rapide. Toujours est-il que chaque accès au cache demande d'accéder à la TLB et de faire la traduction d'adresse avant d'accéder au cache. L'accès est donc plus lent que sur les caches virtuellement tagués, où les accès sont plus directs. [[File:Virtual and Physical addressing.svg|centre|vignette|upright=2|Cache tagué virtuellement versus physiquement tagué.]] ===Les défauts des caches virtuellement tagués=== Les caches physiquement tagués sont moins rapides que les caches virtuellement adressés. Pourtant, les caches virtuellement tagués sont peu fréquents sur les processeurs modernes. Et la raison est assez intéressante : c'est une question d'adresses homonymes et synonymes. ====Les droits d'accès doivent être vérifiés lors d'un accès au cache==== Un premier problème est que la protection mémoire est compliquée avec de tels caches. Rappelons que certaines portions de mémoire sont accessibles seulement en lecture, ou sont interdites en écriture, sont inexécutables, etc. Ces droits d'accès sont gérés par la MMU, qui vérifie pour chaque accès mémoire que l'accès est autorisé. En bypassant la MMU, l'accès au cache virtuellement tagué ne permet pas de faire ces vérifications. Il est possible de charger une donnée en lecture seule dans le cache, mais d'y faire des accès en écriture pour les accès ultérieurs. Les solutions à cela sont multiples. La première consiste à consulter la MMU en parallèle de l'accès au cache. L'accès au cache est alors réalisé de manière spéculative, et est ensuite confirmé/annulé une fois que la MMU a rendu son verdict. Les performances du cache restent alors les mêmes : l'accès à la MMU se fait en parallèle de l'accès au cache, pas avant. Une autre solution est d'ajouter les droits d'accès en question dans la ligne de cache, dans les bits de contrôle situés après le Tag. Chaque accès au cache récupère ces bits de contrôle et vérifie si l'accès est autorisé. L'inconvénient est que les lignes de cache deviennent plus longues, les droits d'accès sont dupliqués entre MMU et cache. Mais si le budget en transistor suit, ce n'est rien d'insurmontable. ====Les adresses homonymes perturbent la gestion du cache==== Pour rappel, une adresse logique homonyme correspond à plusieurs adresses physiques différentes. Elles surviennent quand chaque programme a son propre espace d'adressage. Dans ce cas, une adresse logique correspondra à une adresse physique différente par programme.Une autre manière de voir les choses est qu'il y a en réalité deux adresses homonymes, qui ont la même valeur, mais appartiennent à des espaces d'adressage différentes. Et c'est cette seconde interprétation que nous allons utiliser. Les caches doivent gérer ces adresses homonymes et faire en sorte que la lecture/écriture d'une adresse homonyme se fasse à la bonne adresse physique, dans la bonne ligne de cache. Et autant un cache physiquement tagué n'a aucun problème avec ça, vu qu'il ne gère que des adresses physiques, autant des problèmes surviennent avec les caches virtuellement tagués. Le problème est que les caches virtuellement tagués doivent faire la différence entre deux adresses homonymes de même valeur. Pour corriger ces problèmes, il existe deux grandes méthodes. La première méthode est simple : '''vider les caches''' en changeant de programme. Leur contenu est rapatrié en mémoire RAM, puis les caches sont remis à zéro. Le vidage du cache recopie les lignes de cache ''dirty'' (modifiées) en RAM, puis efface/invalide tout le cache. C'est à cela que servent les instructions ''clean'' et d'invalidation vues plus haut, elles ont été inventées pour cette situation précise. Lorsque le système d'exploitation déclenche une commutation de contexte, à savoir qu'il change le programme en cours d'exécution, le processeur vide tous les caches du processeur. Les interruptions font la même chose, elles vide tous les caches du processeur. Une seconde méthode numérote chaque programme en cours d'exécution, chaque processus. Le numéro attribué est spécifique à chaque processus, ce qui fait qu'il est appelé un '''identifiant de processus CPU'''. Le processeur mémorise l'identifiant du programme en cours d'exécution dans un registre dédié. L'identifiant de processus CPU est utilisé lors des accès mémoire. Chaque ligne de cache contient le numéro de l'espace d'adressage associé, dans son ''tag''. Lors de chaque accès mémoire, l'ID du registre est comparé à l'ID de la ligne de cache accédée, pour vérifier que l'accès mémoire accède à la bonne donnée. Cette méthode n'est pas très économe en termes de transistors. L'usage d'identifiant de processus CPU est clairement meilleure en termes de performance, les commutations de contexte sont plus rapides. Par contre, le budget en transistor est plus important. Un autre défaut de cette méthode est que l'identifiant de processus est généralement codé sur une dizaine de bits, alors que le système d'exploitation utilise des identifiants de processus beaucoup plus larges, de 32 à 64 bits sur les CPU 32/64 bits. L'OS doit gérer la correspondance entre identifiants de processus CPU et ceux de l'OS. Parfois, pour cette raison, les OS n'utilisent pas toujours ce système d'identifiant de processus CPU. ====Les adresses synonymes perturbent aussi la gestion du cache==== La gestion des adresses synonymes est aussi un gros problème sur les caches virtuellement tagués. Pour rappel, il s'agit du cas où des adresses logiques différentes pointent vers la même adresse physique. Typiquement, quand deux programmes se partagent un morceau de mémoire, ce morceau correspondra à des adresses synonymes dans les deux espaces d'adressage. Mais il arrive que l'on ait des adresses synonymes dans le même espace d'adressage, ce n'est pas si rare ! Autant les adresses synonymes ne posent aucun problème avec les caches physiquement tagués, ce n'est pas le cas avec les caches virtuellement adressés. Sur ces caches, deux adresses logiques synonymes vont tomber dans deux lignes de cache différentes. Corriger ce problème demande d'ajouter des circuits annexes pour détecter les adresses synonymes, qui sont vraiment complexes et ont un cout en termes de performance. Aussi, les caches virtuellement tagués sont très peu utilisés sur les processeurs modernes. ===Les caches virtuellement adressés, mais physiquement tagués=== Si les caches physiquement et virtuellement tagués ont des défauts, il existe un intermédiaire qui est un bon compromis entre ces deux extrêmes. Il s'agit des '''caches virtuellement adressés - physiquement tagués''', aussi appelés '''caches pseudo-virtuels'''. Pour comprendre comment ils fonctionnent, précisons que ces caches sont soit des caches ''direct-mapped'', soit des caches associatifs par voie (composés de plusieurs RAM ''direct-mapped'' accédées en parallèle, plusieurs voies). L'accès à ce genre de cache se fait en deux temps : on accède à un ou plusieurs RAM ''direct-mapped'' et on vérifie ensuite les ''Tags'' pour sélectionner la bonne voie. Sur les caches ''direct-mapped'', on n'a qu'une seule RAM ''direct-mapped''. Sur les caches associatifs, on a plusieurs RAM ''direct-mapped'', appelées des voies, qui sont accédées en parallèle. L'accès se fait donc en deux étapes : adresser les RAM ''direct-mapped'' avec un indice, vérifier les ''tags'' avec le reste de l'adresse. Une autre chose à rappeler est que l'adresse logique est composée de deux parties : un numéro de page logique qui indique dans quel page se situe l'adresse, un décalage/''offset'' qui indique la position de l'adresse dans la page. La traduction d'adresse transforme le numéro de page logique en numéro de page physique, mais laisse le décalage intouché. L'idée est d'utiliser le décalage pour adresser les RAM avec le décalage, tandis que le numéro de page sert de ''tag''. Le décalage est découpé en deux lors de l'accès au cache : les bits de poids fort forment l'indice (l'adresse envoyée à la voie), les bits de poids faible donnent la position de l'adresse dans la ligne de cache. L'idée est d'utiliser un numéro de page physique pour les ''tags'', mais d'adresser les voies avec le décalage logique. Les deux servent à des instants différents : vérification des ''tags'' pour l'adresse physique, accès aux voies pour l'adresse logique. Ainsi, le problème des adresses synonymes ou homonymes est résolu par l'utilisation de l'adresse physique pour les tags. Par contre, l'accès au cache est plus rapide, car on utilise l'adresse logique pour la première étape. Le processeur accède à la TLB et récupère l'adresse physique pendant que l'on adresse les voies, les deux sont faits en parallèle, ce qui fait que tout se passe comme si l'accès à la TLB était gratuit. La TLB étant assez rapide comparé au cache, l'adresse physique est disponible quand on doit faire la comparaison avec les ''tags''. [[File:Virtual - Physical - Pseudo Virtual addressing.svg|centre|vignette|upright=2|Adressage pseudo virtuel des caches.]] Il s'agit d'un excellent compromis entre performance et correction des problèmes des adresses synonymes/homonymes. Tous les caches des processeurs haute performance utilisent cette méthode, au moins pour leurs caches L1. Les caches L2 tendent à utiliser des caches physiquement adressés, pour lesquels la latence d'accès est suffisante pour qu'on accède à la TLB en amont. La raison est assez simple à expliquer, elle provient d'une contrainte assez précise sur le calcul de l'indice. La conséquence est qu'un cache ''direct-mapped'' ne peut pas dépasser la taille d'une page, soit 4 kibioctets sur les ordinateurs actuels. Sur les caches associatifs, on peut dépasser cette limite en augmentant le nombre de voies, mais la taille maximale d'une voie reste celle d'une page. Cette contrainte n'est pas trop grave sur les caches de petite taille, dont les caches L1. La plupart d'entre eux ont trouvé un compromis idéal avec moins d'une dizaine de voies par cache, chacun de 4 kibioctets, ce qui donne des caches allant de 16 à 64 kibioctets, soit entre 4 et 16 voies. Par contre, un cache de grande taille doit utiliser un grand nombre de voies, ce qui est peu pratique. Aussi, cette technique de caches pseudo-virtuels n'est pas toujours appliquée sur les caches L2, qui sont physiquement adressés. Il faut dire qu'on accède au cache L2 lors d'un défaut dans le cache L1, et l'adresse physique est disponible à ce moment-là, elle a déjà été récupérée lors de l'accès au cache L1. On peut donc l'utiliser pour adresser le cache L2 sans perte de performance. ==Le remplacement des lignes de cache== Lorsqu'un cache est rempli et qu'on charge une nouvelle donnée dedans, il faut faire de la place pour cette dernière. Dans le cas d'un cache directement adressé, il n'y a rien à faire vu que la ligne de cache à évincer est déterminée lors de la conception du cache. Mais pour les autres caches, la donnée peut aller dans n'importe quelle ligne ou voie. Or, le choix des données à rapatrier en RAM doit être le plus judicieux possible : on doit virer de préférence des données inutiles. Rapatrier une donnée qui sera surement utilisée sous peu est inutile, et il vaudrait mieux supprimer des données qui ne serviront plus ou alors dans longtemps. Il existe différents algorithmes spécialement dédiés à résoudre ce problème efficacement, directement câblés dans les unités de gestion du cache. Certains sont vraiment très complexes, aussi je vais vous présenter quelques algorithmes particulièrement simples. Mais avant de voir ces algorithmes, il faut absolument que je vous parle d'une chose très importante. Quel que soit l'algorithme en question, il choisit la ligne de cache à évincer et recopie son contenu dans la RAM. Ce qui demande d'identifier et de sélectionner une ligne de cache parmi toutes les autres. Pour cela, le circuit de remplacement attribue une adresse chaque ligne de cache ! Vous avez bien vu : chaque ligne de cache est numérotée par une adresse, interne au cache. ===Le remplacement aléatoire=== Premier algorithme : la donnée effacée du cache est choisie au hasard ! C'est contre-intuitif, mais cet algorithme donne des résultats assez honorables, en plus d'utiliser très peu de portes logiques (un générateur de nombres pseudo-aléatoire est un circuit assez simple). Généralement, les défauts de cache sont séparés par un nombre assez important et irrégulier de cycles d'horloge. Dans ces conditions, cette technique donne un bon résultat. ===FIFO : first in, first out=== Avec l'algorithme FIFO, la donnée effacée du cache est la plus ancienne, celle chargée dans le cache avant les autres. Cet algorithme est très simple à implémenter en circuit, concevoir une mémoire de type FIFO n'étant pas très compliqué, comme on l’a vu dans le chapitre dédié à ce type de mémoires. Et on peut dire que dans le cas d'un cache, l'implémentation est encore plus simple et se contente d'un seul registre/compteur. Typiquement, il suffit d'ajouter un registre qui mémorise où se situe la donnée la plus récente. Toute insertion d'une nouvelle donnée se fait à l'adresse suivante, ce qui demande juste d'incrémenter le registre avant d'utiliser son contenu pour l'accès mémoire. [[File:Algorithme FIFO de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme FIFO de remplacement des lignes de cache.]] Cet algorithme possède une petite particularité sur les caches associatifs par voie : en augmentant le nombre d'ensembles, les performances peuvent se dégrader : c'est ce qu'on appelle l''''anomalie de Bélády'''. ===MRU : most recently used=== Avec l'algorithme MRU, la donnée remplacée est celle qui a été utilisée le plus récemment. Cet algorithme s'implémente simplement avec un registre, dans lequel on place le numéro de la dernière ligne de cache utilisée. Cet algorithme de remplacement est très utile quand un programme traverse des tableaux du premier élément jusqu'au dernier : les données du tableau sont rarement réutilisées, rendant le cache inutile. Il est prouvé que dans ces conditions, l'algorithme MRU est optimal. Mais dans toutes les autres conditions, cet algorithme a des performances assez misérables. ===LFU : least frequently used=== Avec l'algorithme LFU, la donnée supprimée est celle qui est utilisée le moins fréquemment. Cet algorithme s'implémente en associant un compteur à chaque ligne de cache, qui est incrémenté à chaque accès mémoire. La ligne la moins récemment utilisée est celle dont le compteur associé a la plus petite valeur. Implémenter cet algorithme prend pas mal de transistors, car il faut rajouter autant de compteurs qu'il y a de lignes de cache, en plus d'un circuit pour comparer les compteurs et d'un encodeur. [[File:Algorithme LFU de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme LFU de remplacement des lignes de cache]] ===LRU : least recently used=== Avec l'algorithme LRU, la donnée remplacée est celle qui a été utilisée le moins récemment. Cet algorithme se base sur le principe de localité temporelle, qui stipule qu'une donnée accédée récemment a de fortes chances d'être réutilisée dans un futur proche. Et inversement, la donnée la moins récemment utilisée du cache est celle qui a le plus de chance de ne servir à rien dans le futur. Autant la supprimer en priorité pour faire de la place à des données potentiellement utiles. Implémenter l'algorithme LRU peut se faire de différentes manières, qui ont pour point commun d'enregistrer les accès au cache pour en déduire la ligne la moins récemment accédée. La manière la plus simple demande d'utiliser un compteur pour chaque ligne de mémoire cache, un peu comme le LFU. La différence avec le LFU est que le compteur n'est pas incrémenté lors d'un accès mémoire. À la place, ce compteur est incrémenté régulièrement, chaque incrémentation ayant lieu en même temps pour tous les compteurs. Quand un bloc est chargé dans le cache, ce compteur est mis à zéro. Quand une ligne de cache doit être remplacée, un circuit va vérifier la valeur de tous les compteurs : la ligne LRU (la moins récemment utilisée), est celle dont le compteur a la valeur la plus haute. Le circuit est composé d'un paquet de comparateurs, et d'un encodeur, comme pour l'agorithme LFU. ===Les approximations du LRU=== Implémenter le LRU demande un nombre de transistors proportionnel au carré du nombre de lignes de cache. Autant dire que le LRU devient impraticable sur de gros caches. Ce qui fait que les processeurs modernes implémentent des variantes du LRU, moins couteuses en transistors, qui donnent un résultat approximativement semblable au LRU. En clair, ils ne sélectionnent pas toujours la ligne de cache la moins récemment utilisée, mais une ligne de cache parmi les moins récemment utilisées. Ce n'est pas un problème si grave que cela car les lignes les moins récemment utilisées ont toutes assez peu de chance d'être utilisées dans le futur. Entre choisir de remplacer une ligne qui a 0,5 % de chances d'être utilisée dans le futur et une autre qui a une chance de seulement 1 %, la différence est négligeable en termes de taux de succès. Mais les gains en termes de circuits ou de temps d'accès au cache de ces algorithmes sont très intéressants. L'algorithme le plus simple consiste à couper le cache (ou chaque voie s'il est associatif) en plusieurs sections. L'algorithme détermine la section la moins récemment utilisée, avant de choisir aléatoirement une ligne de cache dans cette section. Pour implémenter cet algorithme, il nous suffit d'un registre qui mémorise le morceau le moins récemment utilisé, et d'un circuit qui choisit aléatoirement une ligne de cache. Cette technique s'adapte particulièrement bien avec des caches associatifs à voies : il suffit d'utiliser autant de morceaux que de voies. Autre algorithme, un peu plus efficace : le '''pseudo-LRU de type M'''. Cet algorithme attribue un bit à chaque ligne de cache, bit qui sert à indiquer de façon approximative si la ligne de cache associée est une candidate pour un remplacement ou non. Il vaut 1 si la ligne n'est pas une candidate pour un remplacement et zéro sinon. Le bit est mis à 1 lorsque la ligne de cache associée est lue ou écrite. Évidemment, au fil du temps, toutes les lignes du cache finiront par avoir leur bit à 1. Lorsque cela arrive, l'algorithme remet tous les bits à zéro, sauf pour la dernière ligne de cache accédée. L'idée derrière cet algorithme est d'encercler la ligne de cache la moins récemment utilisée au fur et à mesure des accès. L'encerclement commence lorsque l'on remet tous les bits associés aux lignes de cache à 0, sauf pour la ligne accédée en dernier. Au fur et à mesure des accès, l'étau se resserre autour de la ligne de cache la moins récemment utilisée. Après un nombre suffisant d'accès, l'algorithme donne une estimation particulièrement fiable. Et comme les remplacements de lignes de cache sont rares comparés aux accès aux lignes, cet algorithme finit par donner une bonne estimation avant qu'on ait besoin d'effectuer un remplacement. Le dernier algorithme d'approximation, le '''PLURt''', se base sur ce qu'on appelle un arbre de décision. Il a besoin de n − 1 bits pour déterminer la ligne LRU. Ces bits doivent être organisés en arbre, comme illustré plus bas. Chacun de ces bits sert à dire : le LRU est à ma droite ou à ma gauche : il est à gauche si je vaux 0, et à droite si je vaux 1. Trouver le LRU se fait en traversant cet arbre, et en interprétant les bits un par un. Au fur et à mesure des lectures, les bits sont mis à jour dans cet arbre, et pointent plus ou moins bien sur le LRU. La mise à jour des bits s'effectue lors des lectures et écritures : quand une ligne est lue ou écrite, elle n'est pas la ligne LRU. Pour l'indiquer, les bits à 1 qui pointent vers la ligne de cache sont mis à 0 lors de la lecture ou écriture. {| |[[File:Organisation des bits avec l'algorithme PLURt.jpg|vignette|Organisation des bits avec l'algorithme PLURt.]] |[[File:Ligne de cache pointée par les bits de l'algorithme.png|vignette|Ligne de cache pointée par les bits de l'algorithme.]] |} ===LRU amélioré=== L'algorithme LRU, ainsi que ses variantes approximatives, sont très efficaces tant que le programme respecte relativement bien la localité temporelle. Par contre, Le LRU se comporte assez mal dans les circonstances ou la localité temporelle est mauvaise mais où la localité spatiale est respectée, le cas le plus emblématique étant le parcours d'un tableau. Pour résoudre ce problème, des variantes du LRU existent. Une variante très connue, l''''algorithme 2Q''', utilise deux caches : un cache FIFO pour les données accédées une seule fois et un second cache LRU. Évidemment, les données lues une seconde fois sont migrées du cache FIFO vers le cache LRU, ce qui n'est pas très pratique. Les processeurs n'utilisent donc pas cette technique, mais celle-ci est utilisée dans les caches de disque dur. D'autres variantes du LRU combinent plusieurs algorithmes à la fois et vont choisir lequel de ces algorithmes est le plus adapté à la situation. Notre cache pourra ainsi détecter s’il vaut mieux utiliser du MRU, du LRU, ou du LFU suivant la situation. ==Les écritures dans le cache : gestion et optimisations== Les écritures se font à une adresse mémoire bien précise, qui peut ou non être chargée dans le cache. Si la donnée à écrire est chargée dans le cache, elle est modifiée directement dans le cache, mais elle ne l'est pas forcément en mémoire RAM. Suivant le processeur, les écritures sont ou non propagées en mémoire RAM. Il existe deux stratégies d'écritures, appelées respectivement le ''write-back'' et le ''write-through''. Avec un cache ''write-back'', si la donnée à mettre à jour est présente dans le cache, on écrit dans celui-ci sans écrire dans la mémoire RAM. Dans ces conditions, une donnée n'est enregistrée en mémoire que si celle-ci quitte le cache, ce qui évite de nombreuses écritures mémoires inutiles. [[File:Cache write-through.png|centre|vignette|upright=2|Cache write-through.]] Avec les caches '''Write-Through''', toute écriture dans le cache est propagée en RAM. Cette stratégie augmente le nombre d'écritures dans la mémoire RAM, ce qui peut saturer le bus reliant le processeur à la mémoire. Les performances de ces caches sont donc légèrement moins bonnes que pour les caches ''write back''. Par contre, ils sont utiles dans les architectures avec plusieurs processeurs, comme nous le verrons dans les chapitres sur les architectures multiprocesseurs. [[File:Cache write-back.png|centre|vignette|upright=2|Cache write-back.]] ===Les caches ''Write-through''=== Sans optimisation particulière, on ne peut écrire dans un cache ''write-through'' pendant qu'une écriture en RAM a lieu en même temps : cela forcerait à effectuer deux écritures simultanées, en comptant celle imposée par l'écriture dans le cache. Pour éviter cela, certains caches ''write-through'' intègrent un '''tampon d’écriture''', qui sert de file d'attente pour les écritures en RAM. C'est une mémoire FIFO dans laquelle on place temporairement les données à écrire en RAM, où elles attendent en attendant que la RAM soit libre. Grâce à lui, le processeur peut écrire dans un cache même si d'autres écritures sont en attente dans le tampon d'écriture. Par souci d'efficacité, des écritures à la même adresse en attente dans le tampon d’écriture sont fusionnées en une seule. Cela fait un peu de place dans le tampon d’écriture, et lui permet d'accumuler plus d'écritures avant de devoir bloquer le cache. Il est aussi possible de fusionner des écritures à adresses consécutives de la mémoire en une seule écriture en rafales. Dans les deux cas, on parle de '''combinaison d'écriture'''. Mais la technique du tampon d'écriture a cependant un léger défaut qui se manifeste dans une situation bien précise : quand le processeur veut lire une donnée en attente dans le tampon d’écriture. La première manière de gérer cette situation est de mettre en attente la lecture tant que la donnée n'a pas été écrite en mémoire RAM. On peut aussi lire la donnée directement dans le tampon d'écriture, cette optimisation portant le nom de '''''store-to-load forwading'''''. Dans tous les cas, il faut détecter le cas où une lecture accède à une donnée dans le tampon d'écriture. À chaque lecture, l'adresse à lire est envoyée au tampon d'écriture, qui vérifie si une écriture en attente se fait à cette adresse. Pour cela, le tampon d’écriture doit être un cache, dont chaque entrée mémorise une écriture. Chaque ligne de cache contient la donnée à écrire, et le tag de la ligne de cache contient l'adresse où écrire la donnée. Notons que cache d'écriture a une politique de remplacement de type FIFO, le tampon d'écriture non-optimisé étant une mémoire FIFO. ===Les caches ''Write-back''=== Les caches ''write-back'' ont beau avoir des performances supérieures à celles des caches ''write-through'', il existe des optimisations qui permettent d'améliorer leurs performances. Ces optimisations consistent à ajouter des caches spécialisés à côté du cache proprement dit. Ces caches permettent de mémoriser des données qui sont éliminées du cache par les algorithmes de remplacement de ligne cache, sans pour autant faire une écriture en RAM. En suivant la procédure habituelle de remplacement des lignes de cache, on doit rapatrier la ligne en RAM avant d'en charger une nouvelle. On peut améliorer la situation en faisant l'inverse : on charge la nouvelle ligne pendant que l'ancienne donnée est rapatriée en RAM. Ainsi, la nouvelle ligne est disponible plus tôt pour le processeur, diminuant son temps d'attente. Pour implémenter cette technique, on doit mémoriser l'ancienne ligne de cache temporairement dans un '''cache d’éviction''' (ou ''write-back buffer''). [[File:Cache d’éviction.png|centre|vignette|upright=2|Cache d’éviction]] Les caches directement adressés ou associatifs par voie possèdent aussi un tampon d’écriture amélioré. Pour limiter les défauts par conflit de ces caches, des scientifiques ont eu l'idée d'insérer un cache pour stocker les données virées du cache. En faisant ainsi, si une donnée est virée du cache, on peut alors la retrouver dans ce cache spécialisé. Ce cache s'appelle le '''cache de victime'''. Ce cache de victime est géré par un algorithme de suppression des lignes de cache de type FIFO. Petit détail : ce cache utilise un tag légèrement plus long que celui du cache directement adressé au-dessus de lui. L'index de la ligne de cache doit en effet être contenu dans le tag du cache de victime, pour bien distinguer deux adresses différentes, qui iraient dans la même ligne du cache juste au-dessus. [[File:Victim Cache Implementation Example.svg|centre|vignette|upright=1|Cache de victime.]] ===La configuration du fonctionnement du cache=== Sur de nombreux processeurs, il est possible de configurer la mémoire cache pour qu'elle fonctionne soit en mode ''write-back'', soit en mode ''write-through''. Pour cela, les processeurs modernes incorporent des '''registres de configuration du cache'''. Le terme ''registre de configuration du cache'' est assez transparent et indique bien quel est leur rôle. Ils configurent comment le cache est utilisé et permettent notamment de configurer le cache pour dire s'il doit fonctionner en mode ''write-back'' ou ''write-through''. Ils permettent aussi d'activer ou de désactiver la combinaison sur écriture. Les registres en question sont configurés soit par le BIOS, soit par le système d'exploitation. Ce sont des registres protégés, que les applications ne peuvent pas configurer, elles n'en ont pas le droit. Typiquement, ils ne sont accessibles en écriture qu'en mode noyau. Sur les processeurs x86, les registres de configuration du cache sont appelés des '''''Memory type range registers''''' (''MTRRs''). Les MTRRs sont assez nombreux, et il y a notamment une différence entre mode réel et protégé. Si vous vous souvenez des chapitres sur le mode d'adressage et la mémoire virtuelle, vous vous souvenez que les processeurs x86 incorporent plusieurs modes de fonctionnement. En mode réel, le processeur ne peut adresser qu'un mébioctet de RAM, avec un système de segmentation particulier. En mode protégé, le processeur peut adresser toute la mémoire et la segmentation fonctionne différemment, quand elle n'est pas simplement désactivée. Les MTRRs sont séparés en deux : ceux pour le mode réel, ceux pour le mode protégé. Les MTRRs fixes sont ceux qui configurent le cache en mode réel, ils étaient utilisés pour gérer l'accès au BIOS, à la mémoire VGA de la carte graphique, et quelques autres accès aux entrées-sorties basiques gérées nativement par le BIOS. Pour le mode protégé, les processeurs au-delà du 386 incorporent des MTRRs variables, qui servent pour les autres entrées-sorties en général, notamment les périphériques PCI, la mémoire vidéo de la carte graphique, et j'en passe. De nos jours, les registres de configuration du cache sont désuets et cette fonctionnalité est gérée directement par la mémoire virtuelle. La table des pages contient, pour chaque page mémoire, des bits de contrôle qui disent si la page mémoire est cacheable ou non. Le contournement de cache est alors géré par le système de mémoire virtuelle, le cache de TLB et tout ce qui va avec. ===L’allocation sur écriture=== Que faire quand une écriture modifie une donnée qui n'est pas dans le cache ? Doit-on écrire la donnée dans le cache, ou non ? Si la donnée est écrite dans le cache, on dit que le cache fait une '''allocation sur l'écriture''' (ou ''write-allocate''). Certains caches effectuent une telle allocation sur écriture, mais d'autres ne le font pas ou du moins pas systématiquement. L’allocation sur écriture peut se décliner en deux sous-catégories : le '''chargement à la demande''' et l''''écriture immédiate'''. Dans le premier cas, on charge la donnée à modifier dans le cache, et on la remplace avec la donnée écrite. Dans l'écriture immédiate, l'écriture a lieu directement dans le cache et la donnée à modifier n'est pas chargée dans le cache. Évidemment, seule une portion de la ligne de cache contient la donnée écrite (valide), et le reste contient des données invalides. Le cache doit savoir quelles sont les portions du cache qui sont valides : cela demande d'utiliser un ''sector cache''. [[File:Write-back with write-allocation.svg|centre|vignette|upright=2|Cache Write-back avec allocation sur écriture.]] Sans allocation sur écriture, l'écriture est transférée directement aux niveaux de cache inférieurs ou à la mémoire si la donnée à modifier n'est pas dans le cache. Certains caches de ce genre utilisent une petite optimisation : lors de toute écriture, ils supposent que l'écriture donnera un succès de cache. Si c'est le cas, la ligne de cache qui contient la donnée est mise à jour avec la donnée à écrire. Mais si ce n'est pas le cas, la ligne de cache est invalidée, et l'écriture est transférée directement à la mémoire ou aux niveaux de cache inférieurs. [[File:Write-through with no-write-allocation.svg|centre|vignette|upright=2|Cache Write-through sans allocation sur écriture.]] ===La cohérence des caches=== Il arrive parfois que la mémoire d'un ordinateur soit mise à jour, sans que les modifications soient répercutées dans les mémoires cache. Dans ce cas, le cache contient une donnée périmée. Or, un processeur doit toujours éviter de se retrouver avec une donnée périmée et doit toujours avoir la valeur correcte dans ses caches : cela s'appelle la '''cohérence des caches'''. Il est possible de se retrouver avec des valeurs périmées dans le cache sur les ordinateurs avec plusieurs processeurs, ou si un périphérique écrit en RAM, les modifications ne sont pas répercutées automatiquement dans les mémoires cache. Pour résoudre ce problème, on peut interdire de charger dans le cache des données stockées dans les zones de la mémoire dédiées aux périphériques. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires cache. Autre solution : utiliser le fait que les périphériques déclenchent une interruption matérielle pour laisser le contrôleur DMA accéder à la mémoire. Dans ce cas, il suffit de vider les caches à chaque interruption matérielle. Le processeur peut le faire automatiquement, ou fournir des instructions pour. ==Le ''cache bypassing'' : contourner le cache== Dans certaines situations, le cache n'est pas utilisé pour certains accès mémoire. Diverses techniques permettent en effet d'effectuer des accès mémoire qui contournent le cache, qui ne passent pas par le cache. Ils sont utilisés quand l'accès en cache fait que des instructions normales ne fonctionnent pas. Par exemple, de tels accès directs à la RAM sont notamment utilisés pour l'implémentation d'instructions atomiques, une classe d'instructions spécifiques utilisées sur les processeurs multicœurs, dont nous parlerons dans plusieurs chapitres. Mais ils sont aussi utilisés pour l'accès aux périphériques, ce que nous allons voir maintenant. ===Accéder aux périphériques demande de contourner le cache=== Pour rappel, un périphérique (au sens d'entrée-sortie) contient des registres d’interfaçage qui ont une adresse au même titre que les cases mémoire. Un périphérique peut à tout instant modifier ses registres d’interfaçage, ce qui se répercute automatiquement dans l'espace d'adressage, mais rien de tout cela n'est transmis au cache. Si les accès aux périphériques passaient par l'intermédiaire du cache, on aurait droit à des problèmes. On aurait encore une fois droit à des problèmes de cohérence des caches. Le problème est géré différemment suivant que l'on utilise un espace d'adressage séparé ou des entrées-sorties mappées en mémoire. La solution est que les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache. Cela demande d'adapter le cache et le processeur. L'implémentation exacte dépend de comment sont adressés les périphériques. Pour rappel, il y a deux solutions pour adresser les périphériques : soit les périphériques disposent d'un espace d'adressage séparé de celui de la mémoire, soit il y un espace d'adressage unique partagé entre processeur et mémoire. Les deux cas donnent des solutions différentes. Avec un espace d'adressage séparé, l'espace d'adressage des périphériques n'est pas caché : aucun accès dans cet espace d'adressage ne passe par le cache. La mémoire cache n'est utilisée que pour l'espace d'adressage des mémoires, rien d'autre. C'est de loin le cas le plus simple : il suffit de concevoir le processeur pour. Il dispose d'instructions séparées pour les accès aux registres d’interfaçage et à la RAM/ROM, les premières ne passent pas par le cache, les autres si. Avec des entrées-sorties mappées en mémoire, la même solution est utilisée, mais dans une version un peu différente. Là encore, les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache, si on veut qu'ils marchent comme ils le doivent. Cela demande d'adapter le cache et le matériel pour que accès aux périphériques mappés en mémoire contournent le cache. Des adresses, voire des zones entières de la mémoire, sont marquées comme étant non-cachables. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires caches. Là encore, le processeur doit être prévu pour : on doit pouvoir le configurer de manière à marquer certaines zones de la RAM comme non-cacheable. Reste qu'il faut marquer des régions de la RAM comme non-cacheable. Pour cela, on améliore les registres de configuration du cache, vus plus haut, afin qu'ils permettent de configurer certaines portions de la RAM pour préciser qu'elles ne doivent pas être mises en cache, qu'il faut activer le contournement de cache pour celles-ci. ===Contourner le cache pour des raisons de performance=== Il arrive que des données avec une faible localité soient chargées dans le cache inutilement. Or, il vaut mieux que ces données transitent directement entre le processeur et la mémoire, sans passer par l'intermédiaire du cache. Pour cela, le processeur peut fournir des instructions d'accès mémoire qui ne passent pas par le cache, à côté d'instructions normales. De telle instructions sont appelées des '''instructions mémoire non-temporelles'''. Non-temporelle, dans le sens : pas de localité temporelle (c.a.d que les données ne seront pas réutilisées plus tard). Mais il existe aussi des techniques matérielles, où le cache détecte à l'exécution les lectures qui gagnent à contourner le cache. La dernière méthode demande d'identifier les instructions à l'origine des défauts de cache, le processeur accédant directement à la RAM quand une telle instruction est détectée. Si une instruction d'accès mémoire fait trop de défauts de cache, c'est signe qu'elle gagne à contourner le cache. L'idée est de mémoriser, pour chaque instruction d'accès mémoire, un historique de ses défauts de cache. Il existe plusieurs méthodes pour cela, mais toutes demandent d'ajouter de quoi mémoriser l'historique des défauts de cache des instructions. L'historique est mémorisé dans une mémoire appelée la '''table d’historique des défauts de lecture''' (''load miss history table''), qui est souvent un cache. L'historique en question est, dans sa version la plus simple, un compteur de quelques bits incrémenté à chaque succès de cache et décrémenté à chaque défaut de cache, qui indique si l'instruction a en moyenne fait plus de défauts ou de succès de cache. La table associe le ''program counter'' d'une instruction mémoire à cet historique. À la première exécution d'une instruction d'accès mémoire, une entrée de cette table est réservée pour l'instruction. Lors des accès ultérieurs, le processeur récupérer les informations associées et décide s'il faut contourner le cache ou non. ==La hiérarchie mémoire des caches== [[File:Cache Hierarchy.png|vignette|Hiérarchie de caches]] On pourrait croire qu'un seul cache est largement suffisant pour compenser la lenteur de la mémoire. Hélas, les processeurs sont devenus tellement rapides que les caches sont eux-mêmes très lents ! Pour rappel, plus une mémoire peut contenir de données, plus elle est lente. Et les caches ne sont pas épargnés. Si on devait utiliser un seul cache, celui-ci serait très gros et donc trop lent. La situation qu'on cherche à éviter avec la mémoire RAM revient de plus belle. Même problème, même solution : si on a décidé de diviser la mémoire principale en plusieurs mémoires de taille et de vitesse différentes, on peut bien faire la même chose avec la mémoire cache. Depuis environ une vingtaine d'années, un processeur contient plusieurs caches de capacités très différentes : les caches L1, L2 et parfois un cache L3. Certains de ces caches sont petits, mais très rapides : c'est ceux auxquels on va accéder en priorité. Viennent ensuite d'autres caches, de taille variable, mais plus lents. Les processeurs ont donc une hiérarchie de caches qui se fait de plus en plus complexe avec le temps. Cette hiérarchie est composée de plusieurs niveaux de cache, qui vont des niveaux inférieurs proches de la mémoire RAM à des niveaux supérieurs proches du processeur. Plus on monte vers les niveaux supérieurs, plus les caches sont petits et rapides. Un accès mémoire dans une hiérarchie de cache fonctionne comme suit : on commence par vérifier si la donnée recherchée est dans le cache le plus rapide, à savoir le cache L1. Si c'est le cas,n on la charge depuis ce cache directement. Si elle n’y est pas, on vérifie si elle est dans le cache de niveau supérieur, le cache L2. Et rebelote ! Si elle n'y est pas, on vérifie le cache du niveau supérieur. Et on répète cette opération, jusqu’à avoir vérifié tous les caches. Si la donnée n'est dans aucun cache, on doit alors aller chercher la donnée en mémoire. [[File:Hiérarchie de caches.png|centre|vignette|upright=2|Hiérarchie de caches]] Il y a des différences assez notables entre chaque niveau de cache. Par exemple, les différents niveaux de cache n'ont pas forcément les mêmes politiques de remplacement des lignes de cache. Le cache L1 a généralement une politique de remplacement simple, très rapide, mais peu efficace. De même, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Le cache le plus proche de la mémoire est appelé le '''cache de dernier niveau''', ''Last Level Cache'' en anglais. Il a parfois des caractéristiques totalement différentes des autres caches. Par exemple, sur les processeurs multicoeurs, le cache L3 n'est techniquement pas dans le cœur, mais fait partie d'un ensemble de circuits reliés, comme le contrôleur mémoire ou l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc. ===Les caches exclusifs et inclusifs=== Notons que du point de vue de cette vérification, il faut distinguer les caches inclusifs et exclusifs. Avec les caches inclusifs, si une donnée est présente dans un cache, alors elle est présente dans les caches des niveaux inférieurs, ce qui implique l'existence de données en doublon dans plusieurs niveaux de cache. À l'opposé, les caches exclusifs font que toute donnée est présente dans un seul cache, pas les autres. Il existe aussi des caches qui ne sont ni inclusifs, ni exclusifs. Sur ces caches, chaque niveau de cache gère lui-même ses données, sans se préoccuper du contenu des autres caches. Pas besoin de mettre à jour les niveaux de cache antérieurs en cas de mise à jour de son contenu, ou en cas d'éviction d'une ligne de cache. La conception de tels caches est bien plus simple. Dans les '''caches exclusifs''', le contenu d'un cache n'est pas recopié dans le cache de niveau inférieur. Il n'y a pas de donnée en double et on utilise 100 % de la capacité du cache, ce qui améliore le taux de succès. Par contre, le temps d'accès est un peu plus long. La raison est que si une donnée n'est pas dans le cache L1, on doit vérifier l'intégralité du cache L2, puis du cache L3. De plus, assurer qu'une donnée n'est présente que dans un seul cache nécessite aux différents niveaux de caches de communiquer entre eux pour garantir que l'on a pas de copies en trop d'une ligne de cache, ce qui peut prendre du temps. [[File:Caches exclusifs.png|centre|vignette|upright=2|Caches exclusifs]] Dans le cas des '''caches inclusifs''', le contenu d'un cache est recopié dans les caches de niveau inférieur. Par exemple, le cache L1 est recopié dans le cache L2 et éventuellement dans le cache L3. Ce genre de cache a un avantage : le temps d'accès à une donnée est plus faible. La raison est qu'il ne faut pas vérifier tout un cache, mais seulement la partie qui ne contient pas de donnée en doublon. Par exemple, si la donnée voulue n'est pas dans le cache L1, on n'est pas obligé de vérifier la partie du cache L2 qui contient la copie du L1. Ainsi, pas besoin de vérifier certaines portions du cache, ce qui est plus rapide et permet de simplifier les circuits de vérification. En contrepartie, l'inclusion fait que qu'une partie du cache contient des copies inutiles, comme si le cache était plus petit. De plus, maintenir l'inclusion est compliqué et demande des circuits en plus et/ou des échanges de données entre caches. [[File:Caches inclusifs.png|centre|vignette|upright=2|Caches inclusifs]] Maintenir l'inclusion demande de respecter des contraintes assez fortes, ce qui ne se fait pas facilement. Premièrement, toute donnée chargée dans un cache doit aussi l'être dans les caches de niveau inférieur. Ensuite, quand une donnée est présente dans un cache, elle doit être maintenue dans les niveaux de cache inférieurs. De plus, toute donnée effacée d'un cache doit être effacée des niveaux de cache supérieurs : si une donnée quitte le cache L2, elle doit être effacée du L1. Ces trois contraintes posent des problèmes si chaque cache décide du remplacement des lignes de cache en utilisant un algorithme comme LRU, LFU, MRU, ou autre, qui utilise l'historique des accès. En effet, dans ce cas, le cache décide de remplacer les lignes de cache selon l'historique des accès, historique qui varie suivant chaque niveau de cache. Par exemple, une donnée rarement utilisée dans le L2 peut parfaitement être très fréquemment utilisée dans le L1 : la donnée sera alors remplacée dans le L2, mais sera maintenue dans le L1. On observe aussi des problèmes quand il existe plusieurs caches à un seul niveau : chaque cache peut remplacer les lignes de cache d'une manière indépendante des autres caches du même niveau, donnant lieu au même type de problème. Pour maintenir l'inclusion, les caches doivent se transmettre des informations qui permettent de maintenir l'inclusion. Par exemple, les caches de niveaux inférieurs doivent prévenir les niveaux de cache supérieurs quand ils remplacent une ligne de cache. De plus, toute mise à jour dans un cache doit être répercutée dans les niveaux de cache inférieurs et/ou supérieurs. On doit donc transférer des informations de mise à jour entre les différents niveaux de cache. Généralement, le contenu des caches d'instruction n'est pas inclus dans les caches de niveau inférieurs, afin d'éviter que les instructions et les données se marchent sur les pieds. Enfin, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Dans ce cas, l'inclusion est plus difficile à maintenir, pour des raisons assez techniques. ===Les caches eDRAM, sur la carte mère et autres=== D'ordinaire, les mémoires caches sont intégrées au processeur, à savoir que cache et CPU sont dans le même circuit imprimé. Les caches sont donc fabriqués avec de la SRAM, seule forme de mémoire qu'on peut implémenter dans un circuit intégré. Intégrer tous les caches dans le processeur est une solution et efficace. Mais certains processeurs ont procédé autrement. [[File:Cache-on-a-stick module.jpg|vignette|Cache-on-a-stick module]] Des processeurs assez anciens incorporaient un cache L1 dans le processeur, mais plaçaient un cache L2 sur la carte mère. Le cache était clippé sur un connecteur sur la carte mère, un peu comme le sont les barrettes de mémoire. On parlait alors de '''''Cache on a stick''''' (COAST). On aurait pu s'attendre à ce que de tels caches soient en DRAM, vu qu'ils sont placés sur des barrettes de RAM, mais la ressemblance avec la mémoire RAM principale s'arrête là. Le cache était fabriqué en mémoire SRAM, même s'il est en théorie possible de faire de tels caches avec de la DRAM. Les premiers processeurs avec un cache faisaient ainsi, au début des années 90. Il a été introduiot sur les processeurs Motorola, et a été utilisé sur les IBM PC et les Macintosh de l'époque. Les ordinateurs Macintosh utilisaient de tels caches, pour la pluaprt des modèles. Pour ce qui est des PC, les premiers processeurs x86 faisaient pareil, notamment les processeurs Intel. Le 486, le Pentium et le Pentium 2 utilisaient des ''Cache on a stick''. L'avantage est que cela permettait de mettre plus de cache, à une époque où les circuits étaient limités en transistors. De plus, cela permettait au consommateur de choisir quelle quantité de cache il voulait, selon ses finances. Il était possible de laisser le processeur fonctionner soit sans mémoire cache, soit avec un cache de 256 Kibioctets, de 512 Kibioctets, etc. Il était possible d'upgrader le cache si besoin. Pour les CPU Intel, le cache était connecté sur le bus système, au même titre que la mémoire RAM et les entrées-sorties. Il faut dire que les processeurs de l'époque utilisaient un bus système et n'avaient pas de bus mémoire dédié. Mais en théorie, rien n’empêche de connecter le cache sur un bus mémoire dédié. Toujours est-il que les lectures et écritures étaient propagées à la fois dans le cache et la RAM. Les écritures se faisaient dans les deux, systématiquement dans la RAM, mais aussi dans le cache en cas de succès de cache. Les lectures étaient servies soit par le cache en cas de succès de cache, soit par la RAM en cas de défaut de cache. Si le cache répondait en premier, la transaction sur le bus se terminait précocement et l'accès en RAM était abandonné. [[File:Intel486 Иерархия памяти.png|centre|vignette|upright=2.5|Intel486 : le cache était connecté sur le bus système.]] À l'inverse, certains processeurs possédaient un cache fabriqué en mémoire DRAM, et plus précisément avec de la mémoire eDRAM. Le cache n'était pas intégré dans le même circuit imprimé que le processeur, mais profitait d'une architecture en ''chiplet''. Pour rappel, cela veut dire que le processeur est en réalité composé de plusieurs circuits intégré séparés, mais interconnectés et soudés sur un même PCB carré. Avec un cache en eDRAM, le cache avait son propre circuit intégré, séparé du circuit intégré du processeur ou du circuit intégré pour le contrôleur mémoire/IO. Un exemple est celui du cache des processeurs Intel de microarchitecture Broadwell, vus dans ce chapitre dans la section sur les caches splittés. Les tags étaient intégrés dans le circuit intégré du processeur, mais les données étaient mémorisées dans une puce d'eDRAM séparée. La puce eDRAM correspondait en réalité à une DRAM adressable qui servait de DRAM pour les données et mémorisaient les voies du cache. ==Les caches adressés par somme et hashés== Les caches adressés par somme sont optimisés pour incorporer certains calculs d'adresse directement dans le cache lui-même. Pour rappel, certains modes d'adressage impliquent un calcul d'adresse, qui ajoute une constante à une adresse de base. Généralement, l'adresse de base est l'adresse d'un tableau ou d'une structure, et la constante ajoutée indique la position de la donnée dans le tableau/la structure. Les caches hashés et les caches adressés par somme permettent de faire l'addition directement dans la mémoire cache. Voyons d'abord les caches hashés, avant de passer aux caches adressés par somme. Sur les '''caches hashés''', l'addition est remplacée par une autre opération, par exemple des opérations bit à bit du style XOR, AND ou OR, etc. Seulement, utiliser des opérations bit à bit pose un problème : il arrive que deux couples Adresse/décalage donnent le même résultat. Par exemple, le couple Adresse/décalage 11101111/0001 donnera la même adresse que le couple 11110000/0000. Dit autrement, deux adresses censées être différentes (après application du décalage) sont en réalité attribuées à la même ligne de cache. Il est toutefois possible de gérer ces situations, mais cela demande des astuces de haute volée pour faire fonctionner la mémoire cache correctement. Sur les '''caches adressés par somme''', le décodeur est modifié pour se passer de l'addition. Pour comprendre comment, il faut rappeler qu'un décodeur normal est composé de comparateurs, qui vérifient si l'entrée est égale à une constante bien précise. Sur un cache ordinaire, l'addition est faite séparément du décodage des adresses par le cache, dans l'unité de calcul ou dans l'unité de génération d'adresse. [[File:Non sum adressed cache.png|centre|vignette|upright=2|Cache normal.]] Mais les caches adressés par somme modifient le décodeur, qui est alors composé de comparateurs qui testent si la somme adresse + décalage est égale à une constante. [[File:Cache adressé par somme.png|centre|vignette|upright=2|Cache adressé par somme.]] Chaque circuit du décodeur fait le test suivant, avec K une constante qui dépend du circuit : : <math>A + B = K</math> Ce qui est équivalent à faire le test suivant : : <math>A + B - K = 0</math> En complément à deux, on a <math>- K = \overline{K} + 1</math>. En injectant dans l'équation précédente, on a : : <math>A + B + \overline{K} + 1 = 0</math> En réorganisant les termes, on a : : <math>A + B + \overline{K} = - 1</math> Il suffit d'utiliser un additionneur ''carry-save'' pour faire l'addition des trois termes. Rappelons qu'un tel additionneur fournit deux résultats en sortie : une somme calculée sans propager les retenues et les retenues en question. Notons que les retenues sont à décaler d'un cran, vu qu'elles sont censées s'appliquer à la colonne suivante. En notant la somme S et les retenues R, on a: : <math>S + (R << 1) = - 1 </math>, le décalage d'un cran à gauche étant noté <math><< 1</math>. Ensuite, -1 est codé avec un nombre dont tous les bits sont à 1 en complément à un/deux. : <math>S + (R << 1) = 111 \cdots 111111</math> [[File:Sum + retenue add.png|centre|vignette|upright=2|Sum + retenue add]] Un simple raisonnement nous permet de savoir si le résultat est bien -1, sans faire l'addition <math>S + (R << 1)</math>. En effet, on ne peut obtenir -1 que si la somme est l'inverse des retenues : un 0 dans le premier nombre correspond à un 1 dans l'autre, et réciproquement. En clair, on doit avoir <math>\overline{S} = R << 1</math>. Pour vérifier cela, il suffit de faire un simple XOR entre la somme et les retenues décalées d'un cran. On a alors : : <math>S \oplus (R << 1) = 111 \cdots 111111</math> La comparaison avec -1 se fait avec une porte ET à plusieurs entrées. En effet, la porte donnera un 1 seulement si tous les bits d'entrée sont à 1, ce qui est ce qu'on veut tester. Au final, l'additionneur pour l'addition adresse + décalage est remplacé par un additionneur carry-save suivi d'une couche de portes XOR et d'un comparateur avec une constante, ce qui économise de circuits et améliore les performances. [[File:Final circuit of sum addressed cache.png|centre|vignette|upright=2|Cache adressé par somme.]] En prenant en compte que la constante K est justement une constante, certaines entrées de l'additionneur carry-save sont toujours à 0 ou à 1, ce qui permet quelques simplifications à grand coup d’algèbre de Boole. Chaque additionneur complet qui compose l’additionneur carry-save est remplacée par des demi-additionneurs (ou par un circuit similaire). Autant dire que l'on gagne tout de même un petit peu en rapidité, en supprimant une couche de portes logiques. Le circuit de décodage économise aussi des portes logiques, ce qui est appréciable. ==Les caches à accès uniforme et non-uniforme== Intuitivement, le temps d'accès au cache est le même pour toutes les lignes de cache. Il s'agit de cache appelés '''caches à accès uniforme''', sous-entendu à temps d'accès uniforme. Mais sur les caches de grande capacité, il arrive souvent que le temps de propagation des signaux varie fortement suivant la ligne de cache à lire. D'ordinaire, on se cale sur la ligne de cache la plus lente pour caler la fréquence d'horloge du cache, même si on pourrait faire mieux. Cependant, les '''caches à accès non uniforme''' ont une latence différente pour chaque ligne d'un même cache. Certaines lignes de cache sont plus rapides que d'autres. Niveau terminologie, nous allons parler de caches UCA et NUCA : ''Uniform Access Cache'' pour les caches à accès uniforme, ''Non-Uniform Access Cache'' pour les caches à accès non-uniforme. [[File:Caches UCA et NUCA.png|vignette|Caches UCA et NUCA.]] Les caches NUCA et UCA sont souvent composés de plusieurs banques séparées, typiquement une par voie. Sur les caches UCA, les banques sont interconnectées avec le processeur de manière à ce que toutes les interconnexions ont la même longueur pour toutes les banques. Typiquement, les banques sont organisées en carré, avec les interconnexions qui partent du centre, avec une disposition en H, illustrée ci-contre Mais avec les caches NUCA, ce n'est pas le cas. Les interconnexions sont simplifiées et ont des longueurs différentes. Les caches NUCA n'ont pas tous le même genre d'interconnexions, qui dépendent du cache NUCA. En général, les interconnexion forme un réseau avec des sortes de routeurs qui redirigent les données/commandes vers la bonne destination : cache ou processeur. Les banques plus proches du processeur sont accessibles plus rapidement que celles éloignées, même si la différence n'est pas énorme. Les caches NUCA sont généralement associatifs par voie. Les plus simples utilisent une banque par voie pour le cache, ce qui fait que certaines voies répondent plus vite que les autres. La détection des succès de cache est alors plus rapide si la donnée lue/écrite est dans une voie/banque rapide. En théorie, les défauts de cache demandent de vérifier toutes les banques, et se calent donc sur la pire latence. Mais divers caches se débrouillent pour que ce ne soit pas le cas, soit en vérifiant les banquyes unes par une, soit par un mécanisme de recherche plus complexe. Les caches NUCA sont surtout utilisés pour les caches L3 et L4, éventuellement les caches L2. Les caches L1 sont systématiquement des caches UCA, car la latence de l'accès au cache L1 est utilisée par le processeur pour décider quand lancer les instructions. Pour simplifier, le processeur peut démarrer en avance une instruction avant qu'une opérande soit lue dans le cache L1, de manière à ce que la donnée arrive en entrée de l'ALU pile en même temps que l'instruction. Une histoire d'exécution dans le désordre et d'émission anticipée des instructions qu'on détaillera dans une bonne dizaine de chapitres. Toujours est-il que tout est plus simple pour le processeur si le cache L1 a un temps d'accès fixe. Par contre, les caches L3 et L4 sont traités en attendant que les données arrivent, le processeur reprend l'exécution des instructions quand les caches L3 et L4 ont terminé de répondre, pas avant. Avec l'association une banque = une voie, la correspondance ligne de cache → bloc de mémoire qui est statique : on ne peut pas déplacer le contenu d'une ligne de cache dans une autre portion de mémoire plus rapide suivant les besoins. Mais la recherche académique a étudié le cas où la correspondance entre une ligne de cache et une banque varie à l’exécution. Pour nommer cette distinction, on parle de caches S-NUCA (''Static NUCA'') et D-NUCA (''Dynamic NUCA''). Intuitivement, on s'attend à ce que les caches D-NUCA soient plus performants que les caches S-NUCA. Les lignes de cache les plus utilisées peuvent migrer dans une banque rapide, alors que les lignes de cache moins utilisées vont dans une banque éloignée. Les lignes de cache se répartissent dans le cache dynamiquement dans les banques où elles sont le plus adaptées. Mais paradoxalement, le gain des caches D-NUCA est presque nul, voire insignifiant. La raison est que les caches D-NUCA doivent incorporer un système pour déterminer dans quelle banque se situe la donnée pour détecter les succès/défauts de cache, ainsi qu'un système pour migrer les données entre banques. Et ce système augmente le temps d'accès au cache, réduisant à néant l'intérêt d'un cache D-NUCA. Si on économise quelques microsecondes de temps d'accès en passant d'un cache UCA à un cache S-NUCA, ce n'est pas pour les perdre en passant à un D-NUCA. La majorité des caches D-NUCA sont donc en cours de recherche, mais ne sont pas utilisés en pratique. ==La tolérance aux erreurs des caches== Une mémoire cache reste avant tout une mémoire RAM, bien que ce soit de la SRAM. Elle n'est pas parfaite et est donc sujette à des erreurs, qui peuvent inverser un bit ou l'effacer. De telles erreurs sont liées à des rayons cosmiques très énergétiques, à des particules alpha produites par le packaging ou le métal deu circuit intégré, peu importe : l'essentiel est qu'ils inversent parfois un bit. Les mémoires modernes savent se protéger contre de telles erreurs, en utilisant trois moyens. ===Les mémoires caches ECC et à bit de parité=== Le premier moyen est l'usage de codes correcteurs d'erreurs, qui ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Les bits ajoutés dépendent de la donnée mémorisée dans le byte, et servent à détecter une erreur, éventuellement à la corriger. Le cas le plus simple ajoute un simple bit de parité pour chaque byte et se contente de détecter les erreurs dans les corriger. Les autres codes ECC permettent eux de corriger des erreurs, mais ils demandent d'ajouter au moins deux bits par byte, ce qui a un cout en circuit plus élevé. Un simple bit de parité permet de détecter qu'un bit a été inversé, mais ne permet pas de corriger l'erreur. En soi, ce n'est pas un problème. Si une erreur est détectée, on considère que la ligne de cache est invalide. Le cache gère la situation comme un défaut de cache et va chercher la donnée valide en mémoire RAM. Le cout en circuits est donc faible, mais les défauts de cache sont plus nombreux. Les codes ECC sont eux capables de corriger les erreurs, si elles ne modifient pas trop de bits d'un coup. Par contre, ils utilisent deux à trois bits par octet, ce qui a un cout en circuits loin d'être négligeable. Il y a donc un compromis entre défauts de cache et cout en circuits. La gestion de l'ECC est différente suivant le niveau de cache. Généralement, le cache L1 n'utilise pas l'ECC mais se contente d'un simple bit de parité pour éviter la corruption de ses données. Le cache étant petit, les corruptions de données sont assez rares, et les défauts de cache induits faibles. Il est plus important d'utiliser un code de détection d'erreur simple, rapide, qui ne ralentit pas le cache et n'augmente pas sa latence. Si une ligne de cache est corrompue, il a juste à aller lire la ligne depuis le cache L2, ou un niveau de cache inférieur. Du moins, c'est possible sur le cache en question est un cache inclusif et/ou ''write-through''. Par contre, le niveau de cache L2 et ceux en-dessous utilisent presque systématiquement une mémoire SRAM ECC. La raison principale étant que ce sont des caches assez gros, pour lesquels la probabilité d'une erreur est assez élevée. Plus une mémoire a de bits et prend de la place, plus il y a une chance élevée qu'un bit s'inverse. Et vu que les caches L2/L3/L4 sont par nature plus lents et plus gros, ils peuvent se permettre le cout en performance lié à l'ECC, idem pour le cout en circuit. Sans compter qu'en cas d'erreur, ils doivent aller lire la ligne de cache originelle en mémoire RAM, ce qui est très lent ! Mieux vaut corriger l'erreur sur place en utilisant l'ECC. ===L'usage du ''memory scrubbing'' sur les caches=== La plupart des erreurs ne changent qu'un seul bit dans un byte, mais le problème est que ces erreurs s'accumulent. Entre deux accès à une ligne de cache, il se peut que plusieurs erreurs se soient accumulées, ce qui dépasse les capacités de correction de l'ECC. Dans ce cas, il existe une solution appelée le ''memory scrubbing'', qui permet de résoudre le problème au prix d'un certain cout en performance. Pour rappel, l'idée est de vérifier les lignes de caches régulièrement, pour éviter que les erreurs s'accumulent. Par exemple, on peut vérifier chaque ligne de cache toutes les N millisecondes, et corriger une éventuelle erreur lors de cette vérification. En faisant des vérifications régulières, on garantir que les erreurs n'ont pas le temps de s'accumuler, sauf en cas de malchance avec des erreurs très proches dans le temps. Il ne s'agit pas d'un rafraichissement mémoire, car les SRAM ne s'effacent pas), mais ça a un effet similaire. Et évidemment, le ''memory scrubbing'' a un cout en performance. On peut faire une comparaison avec le rafraichissement mémoire : les rafraichissement réguliers réduisent les performances, car cela fait des accès en plus. Des accès qui sont de plus timés à des instants bien précis qui ne sont pas forcément les plus adéquats. Il est possible qu'un rafraichissement ait lieu en même temps qu'un accès mémoire et le rafraichissement a la priorité, ce qui réduit les performances. La même chose arrive avec les vérifications du ''memory scrubbing''. Malgré tout, la technique a été utilisée sur les caches de certains processeurs commerciaux, dont des processeurs AMD Athlon et Athlon 64. Elle est surtout utilisable sur les caches L2/L3, pour lesquels le cout du pseudo-rafraichissement est acceptable. ==Un exemple de cache : le cache d'instruction== La grande majorité des processeurs utilise deux caches L1 séparés : un '''cache d'instructions''' dédié aux instructions, et un autre pour les données. Une telle organisation permet de charger une instruction tout en lisant une donnée en même temps. Notons que seul le cache L1 est ainsi séparé entre cache de données et d'instructions. Le cache d’instruction se situe en théorie entre l'unité de chargement et l'unité de décodage. En effet, ce cache prend en entrée une adresse et fournit une instruction. L'adresse est fournie par le ''program counter'', l'instruction est envoyée dans l'unité de décodage. Le cache se situe donc entre les deux. Le cache de données L1 est connecté au chemin de données, et notamment aux unités de communication avec la mémoire, pas au séquenceur. [[File:Caches L1 et positions dans le processeur.png|centre|vignette|upright=2.5|Caches L1 et positions dans le processeur]] Les deux caches sont reliés au processeur par des bus séparés, l'ensemble ressemble à une architecture Harvard, mais où les caches remplacent les mémoires RAM/ROM. Le cache d'instruction prend la place de la mémoire ROM et le cache de données prend la place de la mémoire RAM. Évidemment, il y a des niveaux de caches en dessous des caches de données/instruction, et ceux-ci contiennent à la fois données et instructions, les deux ne sont pas séparées dans des mémoires/caches séparés. Raison pour laquelle l'ensemble est appelé une '''architecture Harvard modifiée'''. Architecture Harvard, car l'accès aux données et instructions se font par des voies séparées pour le processeur, modifiée car la séparation n'est effective que pour le cache L1 et pas les autres niveaux de cache, et encore moins la RAM. Sur les processeurs modernes, il arrive très souvent que le processeur doive charger une instruction et lire/écrire une donnée en même temps. Et à vrai dire, c'est la règle plus que l'exception. L'usage d'une architecture Harvard modifiée permet cela très facilement : on peut accéder au cache d'instruction via un bus, et au cache de donnée avec l'autre ===Pourquoi scinder le cache L1 en cache d'instruction et de données=== L'usage d'un cache d’instruction séparé du cache de données est à contraster avec l'usage d'un cache L1 multiport unique, capable de mémoriser à la fois instructions et données. Les deux solutions sont possibles ont été utilisées. Les premiers processeurs avaient un cache L1 unique et multiport, mais ce n'est plus le cas sur les processeurs modernes, car les contraintes ne sont pas les mêmes. Le compromis à faire est celui entre deux petits caches rapides et un gros cache plus lent. Pour rappel, plus un cache est petit, plus il est rapide et chauffe moins. Donc au lieu d'utiliser, par exemple, un gros cache lent de 64 Kibioctets, on utilise deux caches de 32 kibioctets, plus rapides. La capacité totale est la même, mais le temps d'accès plus faible. En termes de temps d'accès, la meilleure solution est celle des deux caches simple port. Mais pour ce qui est de l'économie de circuits, c'est moins évident. Entre deux mémoires simple port et une mémoire multiport, la différence en termes de transistors est ambigüe et dépend de la capacité des caches. La différence est surtout notable pour les gros caches, moins pour les petits caches. Il faut aussi tenir compte de la capacité effective. Avec deux caches séparés, la répartition de la capacité du cache L1 est fixée une bonne fois pour toutes. Par exemple, avec un cache d'instruction de 32 KB et un cache de données de 32 KB, impossible d'allouer 40 KB aux données et 20 aux instructions. Alors qu'avec un cache L1 unique de 64 KB, on pourrait le faire sans soucis. La répartition se fait naturellement, en fonction de la politique de remplacement du cache et est proche de l'optimal. C'est là un désavantage des caches d'instructions/données séparés : une capacité effective moindre. Tout cela explique pourquoi le cache L1 est le seul à être ainsi scindé en deux, avec une séparation entre instructions et données : les contraintes au niveau du cache L1 et L2 ne sont pas les mêmes. Pour les caches L1, le temps d'accès est plus important que la capacité, ce qui favorise les caches séparés. Par contre, pour les caches L2/L3/L4, le temps d'accès n'est pas déterminant, alors que la capacité effective et l'économie en circuits sont significatives. ===La connexion des caches L1 avec le cache L2=== Pour les connexions avec le cache L2, tout dépend du processeur. Certains utilisent un cache L2 multiport, qui permet aux deux caches L1 de lire ou écrire dans le cache L2 simultanément. [[File:Cache d'instructions.png|centre|vignette|upright=1.5|Cache d'instructions.]] Si le cache L2 ne gère pas les accès simultanés, il n'y a qu'un seul bus relié aux caches L1 et au cache L2. On doit effectuer un arbitrage pour décider quel cache a la priorité, chose qui est réalisé par un circuit d'arbitrage spécialisé. [[File:Circuit d'arbitrage du cache.png|centre|vignette|upright=1.5|Circuit d'arbitrage du cache.]] Généralement, les caches d'instructions peuvent se permettre d'être plus petits que les caches de données, car les programmes sont souvent plus petits que les données manipulées. Songez que des programmes de quelques mébioctets peuvent parfois remplir la RAM avec plusieurs gibioctets de données. Lancez votre navigateur internet et ouvrez une page web un peu chargée, pour vous en convaincre ! ===Les spécificités du cache d'instruction : lecture seule, bloquant, etc=== Les instructions sont rarement modifiées ou accédées en écritures, contrairement aux données. Et cela permet d'utiliser un cache simplifié pour les instructions. Autant un cache généraliste doit permettre les lectures et écritures depuis le processeur (avec les échanges avec la RAM), autant un cache d'instruction peut se contenter des lectures provenant du CPU et des échanges avec la RAM. Le cache d'instructions est donc très souvent en « lecture seule » : le processeur ne peut pas écrire dedans, mais juste le lire ou charger des instructions dedans. Un cache d'instruction est donc plus simple qu'un cache pour les données : on peut retirer les circuits en charge de l'écriture (mais on doit laisser un port d'écriture pour charger les instructions dedans). Le gain en circuits permet d'utiliser un cache d'instruction plus gros ou au contraire de laisser de la place pour le cache de données. Le gain en termes de capacité compense alors un peu les inconvénients des caches séparés. Par contre, cela complique la gestion du code automodifiant, c'est-à-dire des programmes dont certaines instructions vont aller en modifier d'autres, ce qui sert pour faire de l'optimisation ou est utilisé pour compresser ou cacher un programme (les virus informatiques utilisent beaucoup de genre de procédés). Quand le processeur exécute ce genre de code, il ne peut pas écrire dans ce cache L1 d'instructions, mais doit écrire dans le cache L2 ou en RAM, avant de recharger les instructions modifiées dans le cache L1. Cela qui prend du temps et peut parfois donner lieu à des erreurs si le cache L1 n'est pas mis à jour. Les algorithmes de remplacement des lignes de cache optimaux pour les données ne le sont pas pour les instructions, de même que la taille optimale du cache, la taille des lignes de cache optimale, ou même les algorithmes de préchargement. Par exemple, pour le remplacement des lignes de cache, un simple algorithme LRU est presque optimal pour les instructions, autant il peut donner de mauvaises performances quand on manipule beaucoup de tableaux. Cela justifie d'utiliser des caches spécialisés pour chacune. On peut adapter le cache d'instruction à son contenu, ce qui le rend plus rapide ou plus petit à performance égale. Les caches d'instructions sont généralement des caches bloquants. Il ne servirait à rien de rendre un cache d'instruction non-bloquant, le cout en circuits ne se traduirait pas par une augmentation significative des performances. À l'opposé, les caches de données sont non-bloquants sur les architectures modernes, pour des raisons de performance. Ce qui rend la séparation assez intéressante, les deux caches ayant des besoins différents et des implémentations différentes, cela permet d'optimiser le cout en transistors des caches. ===L'impact du cache d'instruction sur les performances=== Sur les architectures conventionnelles, le cache d'instruction a plus d'impact sur les performances que le cache de données. La raison principale est que les instructions ont une meilleure localité spatiale et temporelle que pour les données. Pour la localité spatiale, les instructions consécutives se suivent en mémoire, alors que rien ne garantit que des données utilisées ensemble soient regroupées en mémoire. Pour localité temporelle, elle est très variable pour les données, mais très courante pour les instructions du fait de l'usage fréquent des boucles et des fonctions. : La présence de branchements atténue la localité temporelle des instruction, sauf que la majorité des branchements sautent à un endroit très proche, seuls les appels de fonction brisent la localité spatiale. La conséquence est qu'il arrive que certains CPU aient un cache L1 d'instruction plus gros que celui pour les données. On parle alors de '''cache L1 asymétriques'''. Un exemple est celui des processeurs AMD de microarchitecture Zen, dont le cache d'instruction était deux fois plus gros que le cache de données. Leur cache d'instruction faisait 64 kibioctets, contre seulement 32 pour le cache de données. D'ailleurs, il existe des processeurs assez extrêmes qui se contentent d'un cache d'instruction unique, sans cache de données. C'est le cas sur les processeurs vectoriels ou les GPU que nous verrons dans les chapitres de fin de ce wikilivres. De tels processeurs sont spécialisés dans la manipulation de tableaux de données, traitement qui a une faible localité temporelle. En conséquence, utiliser un cache de données n'est pas vraiment utile, voire peu être contreproductif, alors qu'un cache d’instruction fonctionne parfaitement. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Compléments sur les mémoires de masse | prevText=Compléments sur les mémoires de masse | next=Le préchargement | nextText=Le préchargement }} </noinclude> lopvp1qoe4z04v2waeahnht4u12l8m2 765131 765130 2026-04-26T16:44:19Z Mewtow 31375 /* Le contrôleur de cache 82385 pour les CPU Intel 386 */ 765131 wikitext text/x-wiki Le cache est une mémoire intercalée entre la mémoire et un processeur, plus rarement à l'intérieur d'un périphérique. Il est souvent fabriquée avec de la mémoire SRAM, parfois avec de l'eDRAM. Sans lui, on se croirait à l'âge de pierre tellement nos PC seraient lents ! En effet, la mémoire est très lente comparée au processeur. Le temps mis pour accéder à la mémoire est du temps durant lequel le processeur n'exécute pas d'instruction (sauf cas particuliers impliquant un pipeline). Pour diminuer ce temps d'attente, il a été décidé d'intercaler une mémoire petite mais rapide, entre le processeur et la mémoire. Ainsi, le processeur accède à un cache très rapide plutôt qu'à une RAM beaucoup plus lente. ==L'accès au cache== Le cache contient une copie de certaines données présentes en RAM. La copie présente dans le cache est accessible bien plus rapidement que celle en RAM, vu que le cache est plus rapide. Mais seule une petite partie de ces données sont copiées dans le cache, les autres données devant être lues ou écrites dans la RAM. Toujours est-il que le cache contient une copie des dernières données accédées par le processeur. Une donnée est copiée dans la mémoire cache quand elle est lue ou écrite par le processeur. Le processeur conserve une copie de la donnée dans le cache après son premier accès. Les lectures/écritures suivantes se feront alors directement dans le cache. Évidemment, au fur et à mesure des accès, certaines données anciennes sont éliminées du cache pour faire de la place aux nouveaux entrants, comme nous le verrons plus tard. [[File:Principe d'une mémoire cache.gif|centre|vignette|upright=2|Principe d'une mémoire cache.]] La mémoire cache est invisible pour le programmeur, qui ne peut pas déceler celles-ci dans l'assembleur. Les accès mémoire se font de la même manière avec ou sans le cache. La raison à cela est que le cache intercepte les accès mémoire et y répond s'il en a la capacité. Par exemple, si le cache intercepte une lecture à une adresse et que le contenu de cette adresse est dans le cache, le cache va outrepasser la mémoire RAM et la donnée sera envoyée par le cache au lieu d'être lue en RAM. par contre, si un accès se fait à une adresse pour laquelle le cache n'a pas la donnée, alors l'accès mémoire sera effectué par la RAM de la même manière que si le cache n'était pas là. [[File:Accès au cache.png|centre|vignette|upright=2|Accès au cache]] ===Les succès et défauts de caches=== Tout accès mémoire est intercepté par le cache, qui vérifie si la donnée demandée est présente ou non dans le cache. Si la donnée voulue est présente dans le cache, on a un '''succès de cache''' (''cache hit'') et on accède à la donnée depuis le cache. Sinon, c'est un '''défaut de cache''' (''cache miss'') et on est obligé d’accéder à la RAM. Les défauts de cache peuvent avoir plusieurs origines. Tout ce qu'il faut savoir est que lorsque le processeur accède à une donnée ou une instruction pour la première fois, il la place dans la mémoire cache car elle a de bonnes chances d'être réutilisée prochainement. La raison à cela est qu'un programme a tendance à réutiliser les instructions et données qui ont été accédées dans le passé : c'est le ''principe de localité temporelle''. Bien évidement, cela dépend du programme, de la façon dont celui-ci est programmé et accède à ses données et du traitement qu'il fait, mais c'est souvent vrai en général. La première cause des défauts de cache est liée à la taille du cache. À force de charger des données/instructions dans le cache, le cache fini par être trop petit pour conserver les anciennes données. Le cache doit bien finir par faire de la place en supprimant les anciennes données, qui ont peu de chances d'être réutilisées. Ces anciennes données éliminées du cache peuvent cependant être accédées plus tard. Tout prochain accès à cette donnée mènera à un cache miss. C'est ce qu'on appelle un ''Capacity Cache Miss'', ou encore '''défaut de capacité'''. Les seules solutions pour éviter cela consistent à augmenter la taille du cache ou à optimiser le programme exécuté (voir plus bas). Une autre raison pour un défaut est donc la suivante. Lorsqu'on exécute à une instruction ou qu'on accède à donnée pour la première fois, celle-ci n'a pas encore été chargée dans le cache. Le défaut de cache est inévitable : ce genre de cache miss s'appelle un ''Cold Miss'', ou encore un '''défaut à froid'''. De tels défauts sont presque impossibles à éliminer, sauf à utiliser des techniques de préchargement qui chargent à l'avance des données potentiellement utiles. Ces méthodes de préchargement se basent sur le principe de localité spatiale, à savoir le fait que les programmes ont tendance à accéder à des données proches en mémoire. Pour donner un exemple, les instructions d'un programme sont placées en mémoire dans l’ordre dans lequel on les exécute : la prochaine instruction à exécuter est souvent placée juste après l'instruction en cours (sauf avec les branchements). Quand on accède à une donnée ou une instruction, le cache peut précharger les données adjacentes pour en profiter. Nous parlerons de ces techniques de préchargement dans un chapitre dédié, vers la fin du cours. ===Le fonctionnement du cache, vu du processeur=== Vu du processeur, le cache prend en entrée toutes les informations nécessaires pour effectuer un accès mémoire : des signaux de commande, une adresse et la donnée à écrire si besoin. Tout cela est passé en entrée du cache, celui-ci répondant aux accès mémoire via divers bits de contrôles, que le processeur peut lire à souhait. Le cache fournit aussi la donnée à lire, pour les lectures, sur une sortie, connectée directement au bus mémoire/processeur. Globalement, le cache a une capacité limitée, mais il prend en entrée des adresses complètes. Par exemple, sur un processeur 64 bits, le cache prend en entrée des adresses de 64 bits (sauf si optimisations), même si le cache en question ne fait que quelques mébioctets. Les caches sont souvent des mémoires multiports, surtout sur les processeurs récents. Les caches simple port sont rares, mêmes s'ils existent et ont existé par le passé. les caches double port sont eux plus fréquents, et ont généralement un port d'écriture séparé du port de lecture. Mais les caches récents ont plusieurs ports de lecture/écriture et sont capables de gérer plusieurs accès mémoire simultanés. Les données présentes dans le cache sont (pré)chargées depuis la mémoire, ce qui fait que toute donnée dans le cache est la copie d'une donnée en mémoire RAM. Le cache doit faire la correspondance entre une donnée du cache et l'adresse mémoire correspondante. Du point de vue du fonctionnement, on peut voir le cache comme une sorte de table de correspondance, qui mémorise des données, chacune étant associée à son adresse mémoire. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Cela vaut du point de vue du processeur, le fonctionnement interne du cache étant quelque peu différent selon le cache. Il existe des caches dont le fonctionnement interne est bien celui d'une table de correspondance matérielle, d'autres qui sont beaucoup plus optimisés. [[File:Fonctionnement d'une mémoire associative à correspondance.png|centre|vignette|upright=2|Fonctionnement simplifié d'une mémoire cache : les adresses sont dans la colonne de gauche, les données sont dans la colonne de droite. On voit qu'on envoie l'adresse au cache, que celui-ci répond en renvoyant la donnée associée.]] ==La performance des mémoires caches== L'analyse de la performance des mémoires caches est plus riche pour celle des autres mémoires. Sa performance dépend de beaucoup de paramètres, mais on peut cependant citer les principaux. Les deux premiers sont tout bonnement sa latence et son débit, comme pour n'importe quelle autre mémoire. La latence est plus importante que son débit, car le processeur est généralement plus rapide que le cache et qu'il n'aime pas attendre. Mais le critère le plus important pour un cache est sa capacité à empêcher des accès mémoire, son efficacité. Plus les accès mémoire sont servis par le cache au lieu de la RAM, meilleures seront les performances. Pour résumer, la performance d'un cache est surtout caractérisée par deux métriques : le taux de défaut, qui correspond à l’efficacité du cache, et la latence du cache. ===Le taux de succès/défaut=== Le '''taux de succès''' (hit ratio) est un premier indicateur des performances du cache, mais un indicateur assez imparfait. C'est le pourcentage d'accès mémoire qui ne déclenchent pas de défaut de cache. Plus il est élevé, plus le processeur accède au cache à la place de la RAM et plus le cache est efficace. Certains chercheurs préfèrent utiliser le '''taux de défauts''', à savoir le pourcentage d'accès mémoire qui entraînent un défaut de cache. Plus il est bas, meilleures sont les performances. Le taux de défaut est relié au taux de succès par l'équation <math>T_\text{succes} = 1 - T_\text{defaut}</math>. Par définition, il est égal à : : <math>\text{Taux de défauts de cache} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d’accès mémoires}}</math> Plutôt que de comparer le nombre de défauts/succès de cache au nombre d'accès mémoire, il est aussi possible de diviser le nombre de défauts par le nombre total d'instructions. On obtient alors le '''taux de défauts/succès par instruction''', une autre métrique utile. Par définition, elle est égale à : : <math>\text{Taux de défauts par instruction} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d'instructions}} = \text{Taux de défauts de cache} \times \frac{\text{Nombre d’accès mémoires}}{\text{Nombre d'instructions}}</math> Si certains défauts de cache sont inévitables quel que soit le cache, comme les défauts à froids, mentionnés plus haut, d'autres défauts peuvent être évités en augmentant la capacité du cache. C'est le cas des défauts de capacité qui sont causés par un accès à une donnée qui a été éliminée du cache faute de place. Plus le cache est gros, moins il a de chances d'être rempli, moins il doit rapatrier de données, plus son taux de succès augmente. Mais nous reviendrons sur le lien entre taille du cache et taux de défaut plus bas. Le taux de succès ne dépend pas que du cache, mais aussi de la conception des programmes exécutés. Une bonne utilisation du cache (ainsi que de la mémoire virtuelle) repose sur le programmeur qui doit prendre en compte les principes de localités dès la conception de ses programmes. Par exemple, un programmeur peut parfaitement tenir compte du cache au niveau de son algorithme : on peut citer l'existence des algorithmes ''cache oblivious'', qui sont conçus pour être optimaux quelle que soit la taille du cache. Le programmeur peut aussi choisir ses structures de données de manière à améliorer la localité. Par exemple, un tableau est une structure de donnée respectant le principe de localité spatiale, tandis qu'une liste chaînée ou un arbre n'en sont pas (bien qu'on puisse les implémenter de façon à limiter la casse). D'autres optimisations sont parfois possibles : par exemple, le sens de parcours d'un tableau multidimensionnel peut faire une grosse différence. Cela permet des gains très intéressants pouvant se mesurer avec des nombres à deux ou trois chiffres. Je vous recommande, si vous êtes programmeur, de vous renseigner le plus possible sur les optimisations de code ou algorithmiques qui concernent le cache : il vous suffira de chercher sur Google. Il y a une citation qui résume bien cela, prononcée par un certain Terje Mathisen. Si vous ne le connaissez pas, cet homme est un vieux programmeur (du temps durant lequel on codait encore en assembleur), grand gourou de l’optimisation, qui a notamment travaillé sur le moteur de Quake 3 Arena. {{BlocCitation|Almost all programming can be viewed as an exercise in caching.|auteur=Terje Mathisen}} ===La latence moyenne d'un cache=== Le temps mis pour lire ou écrire une donnée varie en présence d'un cache. Certaines lectures/écritures vont atterrir directement dans le cache (succès) tandis que d'autres devront aller chercher leur contenu en mémoire RAM (défaut de cache). Dans tous les cas, qu'il y ait défaut ou non, le cache sera consulté et mettra un certain temps à répondre, égal au temps de latence du cache. Tous les accès mémoires auront donc une durée au moins égale au temps de latence du cache, qui sera notée <math>T_c</math>. En cas de succès, le cache aura effectué la lecture ou l'écriture, et aucune action supplémentaire n'est requise. Ce qui n'est pas le cas en cas de défaut : le processeur devra aller lire/écrire la donnée en RAM, ce qui prend un temps supplémentaire égal au temps de latence de la mémoire RAM. Un défaut ajoute donc un temps, une pénalité, à l'accès mémoire. Dans ce qui suivra, le temps d'accès à la RAM sera noté <math>T_m</math>. Fort de ces informations, nous pouvons calculer le temps de latence moyen d'un accès mémoire, qui est la somme du temps d'accès au cache (pour tous les accès mémoire), multiplié par le temps lié aux défauts. On a alors : : <math>T = T_c + \text{Taux de défaut} \times T_m</math> On voit que plus le taux de succès est élevé, plus le temps de latence moyen sera bas, et inversement. Ce qui explique l'influence du taux de succès sur les performances du cache, influence assez importante sur les processeurs actuels. De nos jours, le temps que passe le processeur dans les défauts de cache devient de plus en plus un problème au fil du temps, et gérer correctement le cache est une nécessité, particulièrement sur les processeurs multi-cœurs. Il faut dire que la différence de vitesse entre processeur et mémoire est tellement importante que les défauts de cache sont très lents : alors qu'un succès de cache va prendre entre 1 et 5 cycles d'horloge, un cache miss fera plus dans les 400-1000 cycles d'horloge. Tout ce temps sera du temps de perdu que le processeur aura du mal à mitiger. Autant dire que réduire les défauts de cache est beaucoup plus efficace que d'optimiser les calculs effectués par le processeur (erreur courante chez de nombreux programmeurs, notamment débutants). ===L'impact de la taille du cache sur le taux de défaut et la latence=== Il y a un lien entre taille du cache, taux de défaut, débit binaire et latence moyenne. Globalement, plus un cache est gros, plus il est lent. Simple application de la notion de hiérarchie mémoire vue il y a quelques chapitres. Les raisons à cela sont nombreuses, mais nous ne pouvons pas les aborder ici, car il faudrait que nous sachions comment fonctionne un cache et ce qu'il y a à l'intérieur, ce qui sera vu dans la suite du chapitre. Toujours est-il que la latence moyenne d'un cache assez gros est assez importante. De même, le débit binaire d'un cache diminue avec sa taille, mais dans une moindre mesure. Les petits caches ont donc un gros débit binaire et une faible latence, alors que c'est l'inverse pour les gros caches. Une grande capacité de cache améliore le taux de succès, mais cela se fait au détriment de son temps de latence et de son débit, ce qui fait qu'il y a un compromis assez difficile à trouver entre taille du cache, latence et débit. Il peut arriver qu'augmenter la taille du cache augmente son temps d'accès au point d’entraîner une baisse de performance. Par exemple, les processeurs Nehalem d'Intel ont vus leurs performances dans certains jeux vidéos baisser de 2 à 3 %, malgré de nombreuses améliorations architecturales, parce que la latence du cache L1 avait augmentée de 2 cycles d'horloge. Pour avoir une petite idée du compromis à faire, regardons la relation entre taille du cache et taux de défaut. Il existe une relation approximative entre ces deux variables, appelée la '''loi de puissance des défauts de cache'''. Elle donne le nombre total de défaut de cache en fonction de la taille du cache et de deux autres paramètres. Voici cette loi : : <math>\text{Taux de défauts de cache} \approx K \times \text{Taille du cache}^{- \alpha }</math>, avec <math>K</math> et <math>\alpha</math> deux coefficients qui dépendent du programme exécuté. Le coefficient <math>\alpha</math> est généralement compris entre 0.3 et 0.7, guère plus, et varie suivant le programme exécuté. Précisons que cette loi ne marche que si le cache est assez petit par rapport aux données à utiliser. Pour un cache assez gros et des données très petites, la relation précédente est mise en défaut. Pour s'en rendre compte, il suffit d'étudier le cas extrême où toutes les données nécessaires tiennent dans le cache. Dans ce cas, il n'y a qu'un nombre fixe de défauts de cache : autant qu'il faut charger de données dans le cache. Le nombre de défauts de cache observé dans cette situation n'est autre que le coefficient <math>K</math> de la situation précédente, mais il n'y a aucune dépendance entre taux de défaut et taille du cache. L'origine de cette relation s'explique quand on regarde combien de fois chaque donnée est réutilisée lors de l’exécution d'un programme. La plupart des données finissent par être ré-accédées à un moment ou un autre et il se passe un certain temps entre deux accès à une même donnée. Sur la plupart des programmes, les observations montrent que beaucoup de réutilisations de données se font après un temps très court et qu'inversement, peu de ré-accès se font après un temps inter-accès long. Si on compte le nombre de réutilisation qui ont un temps inter-accès bien précis, on retrouve une loi de puissance identique à celle vue précédemment : : <math>\text{Nombre de réaccès avec un temps inter-accès égal à t} \approx K \times t^{- \beta}</math>, avec t le temps moyen entre deux réutilisations. Le coefficient <math>\beta</math> est ici compris entre 1.7 et 1.3. De manière générale, les coefficients <math>\alpha</math> et <math>\beta</math> sont reliés par la relation <math>\alpha = 1 - \beta</math>, ce qui montre qu'il y a un lien entre les deux relations. Précisons cependant que la loi de puissance précédente ne vaut pas pour tous les programmes informatiques, mais seulement pour la plupart d’entre eux. Il n'est pas rare de trouver quelques programmes pour lesquels les accès aux données sont relativement prédictibles et où une bonne optimisation du code fait que la loi de puissance précédente n'est pas valide. La loi de puissance des défauts de cache peut se démontrer à partir de la relation précédente, sous certaines hypothèses. Si un suppose que le cache est assez petit par rapport aux données, alors les deux relations sont équivalentes. L'idée qui se cache derrière la démonstration est que si le temps entre deux accès à une donnée est trop long, alors la donnée accédée aura plus de chance d'être rapatriée en RAM, ce qui cause un défaut de cache. La chance de rapatriement dépend de la taille du cache, un cache plus gros peut conserver plus de données et a donc un temps avant rapatriement plus long. ==Les lignes de cache et leurs tags== Du point de vue du processeur, les lectures et écritures se font mot mémoire par mot mémoire. Un processeur avec des entiers de 64 bits recoit des données de 64 bits de la part du cache, et y écrit des mots de 64 bits. Mais quand on regarde comment sont stockées les données à l'intérieur du cache, les choses sont différentes. ===Les lignes de cache=== Les données sont mémorisées dans le cache par blocs de plusieurs bytes, d'environ 64 à 256 octets chacun, qui portent le nom de '''lignes de cache'''. Les lignes de cache sont l'unité de stockage que l'on trouve à l'intérieur du cache, mais elles servent aussi d'unité de transaction avec la mémoire RAM. Sur les caches actuels, on transfère les données entre le cache et la RAM ligne de cache par ligne de cache, dans la limite de la taille du bus mémoire. Mais d'autres caches plus anciens permettaient de faire des transferts plus fins. C’est-à-dire qu'on pouvait mettre à jour quelques octets dans une ligne de cache sans avoir à la recopier intégralement depuis ou dans la mémoire RAM. En théorie, on pourrait imaginer des caches où les données sont stockées différemment, où l'unité serait le mot mémoire, par exemple. Par exemple, sur un processeur 64 bits, on aurait une ligne de cache de 64 bits. Cela aurait l'avantage de la simplicité : les transferts entre le processeur et la mémoire serait de même taille, l'intérieur du cache ressemblerait à son interface montrée au processeur. Mais cela aurait quelques défauts qui sont compensés par l'organisation en lignes de cache de grande taille. Le premier avantage des lignes de cache est lié à la localité spatiale, la tendance qu'on les programmes à accéder à des données proches les unes des autres. Des accès mémoires consécutifs ont tendance à se faire à des adresses proches, qui ont de bonnes chances d'être dans la même ligne de cache. Et des accès consécutifs à une même ligne de cache sont plus rapides que des accès à deux lignes distinctes. Une autre raison est tout simplement que cela simplifie considérablement la circuiterie du cache. Pour une capacité identique, il vaut mieux avoir peu de lignes de cache assez grosses, que beaucoup de petites lignes de cache. La raison est que les circuits du cache, comme le décodeur, l'encodeur et autres, ont moins de sorties et sont donc plus simples. ===L'alignement des lignes de cache=== Les lignes de cache sont des blocs de plusieurs dizaines à centaines de bytes, dont la taille est presque toujours une puissance de deux. De plus, les lignes de cache sont alignées en mémoire. Nous avions déjà abordé la notion d'alignement mémoire dans un chapitre précédent, mais le concept d'alignement des lignes de cache est quelque peu différent. Quand nous avions parlé d'alignement auparavant, il s'agissait de l'alignement des données manipulées par le processeur, qui faisait partie du jeu d'instruction du processeur. Ici, nous parlons d'un alignement totalement différent, invisible pour le programmeur, sans lien avec le jeu d’instruction. Voyons de quoi il retourne. Concrètement, cela veut dire que du point de vue du cache, la RAM est découpée en blocs qui font la même taille qu'une ligne de cache, aux positions prédéterminées, sans recouvrement entre les blocs. Par exemple, pour un cache dont les lignes de cache font 256 octets, le premier bloc est à l'adresse 0, le second est 256 octets plus loin, c'est à dire à l'adresse 256, le troisième à l'adresse 512, la quatrième à l'adresse 768, etc. Une ligne de cache de 256 octets contiendra une donnée provenant d'un bloc de RAM de 256 octets, dont l'adresse est systématiquement un multiple de 256. Il n'est pas possible qu'une ligne de cache contienne un bloc de 256 octets dont l'adresse du premier octet serait l'adresse 64, ou l'adresse 32, par exemple. En clair, les adresses de ces blocs sont des multiples de la taille de la ligne de cache, de la taille des blocs. Cela rappelle les contraintes d'alignement vues dans le chapitre "Le modèle mémoire : alignement et boutisme", mais appliquées aux lignes de cache. L'alignement des lignes de cache a des conséquences pratiques pour la conception des caches. Notons qu'il est en théorie possible d'avoir des caches dont les lignes de cache ne sont pas alignées, mais cela poserait des problèmes majeurs. Il serait en effet possible qu'une donnée soit présente dans deux lignes de cache à la fois. Par exemple, prenons le cas où une ligne de cache de 256 commence à l'adresse 64 et une autre ligne de cache commence à l'adresse 0. L'adresse 128 serait dans les deux lignes de cache ! Et cela poserait des problèmes lors des lectures, mais encore plus lors des écritures. C'est pour éviter ce genre de problèmes que les lignes de cache sont alignées avec la mémoire RAM dans tous les caches existants. L'alignement des lignes de cache est une chose que les programmeurs doivent parfois prendre en compte quand ils écrivent du code ultra-optimisé, destiné à des programmes demandant des performances extrêmes. Il arrive que les contraintes d'alignement posent des problèmes. Nous avions vu dans le chapitre sur le boutisme et l'alignement qu'il valait mieux gérer l'alignement des variables des structures de données, pour éviter les accès non-alignés avec le bus mémoire. La même chose est possible, mais pour l'alignement avec des lignes de cache. Typiquement, l'idéal est que, pour une structure de donnée, on puisse en mettre un nombre entier dans une ligne de cache. Ou alors, si la structure est vraiment grande, que celle-ci occupe un nombre entier de lignes de cache. Si ce n'est pas le cas, il y a un risque d'accès non-alignés, c'est à dire qu'une structure se retrouve à cheval sur deux lignes de cache, avec les défauts que cela implique. ===Le tag d'une ligne de cache=== Plus haut, nous avions dit que le cache mémorise, pour chaque ligne de cache, l'adresse RAM associée. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Mais du fait de l'organisation du cache en lignes de cache de grande taille, qui sont de plus alignées en mémoire, il faut nuancer cette affirmation. Le cache ne mémorise pas la totalité de l'adresse, ce qui serait inutile. L'alignement des lignes de cache en RAM fait que les bits de poids faible de l'adresse ne sont pas à prendre en compte pour l'association adresse-ligne de cache. Dans ces conditions, on mémorise seulement la partie utile de l'adresse mémoire correspondante, qui forme ce qu'on appelle le '''tag'''. Le reste de l'adresse indique quelle est la position de la donnée dans la ligne de cache. Par exemple, prenons le cas où le processeur gère des nombres entiers de 64 bits (8 octets) et des lignes de cache de 128 octets : chaque ligne de cache contient donc 16 entiers. Si le processeur veut lire ou écrire un entier bien précis, il doit préciser sa place dans la ligne de cache. Et ce sont les bits de l'adresse mémoire non-inclus dans le cache qui permettent de faire ça. En clair, une adresse mémoire à lire/écrire est interprété par le cache comme la concaténation d'un tag et de la position de la donnée dans la ligne de cache correspondante. [[File:Adressage d'un cache totalement associatif.png|centre|vignette|upright=2|Adressage d'un cache totalement associatif]] Le cache est donc une grande table de correspondance entre tags et lignes de cache. Lors d'un accès mémoire, le cache extrait le tag de l'adresse à lire ou écrire, et le compare avec les tags de chaque ligne de cache. Si une ligne contient ce tag, alors c'est que cette ligne correspond à l'adresse, et c'est un défaut de cache sinon. Lors d'un succès de cache, la ligne de cache est lue depuis le cache et envoyée à un multiplexeur qui sélectionne la donnée à lire dans la ligne de cache. Le fonctionnement est similaire pour une écriture : la donnée à écrire passe dans un démultiplexeur, qui envoie la donnée au bon endroit dans la ligne de cache sélectionnée. [[File:Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.png|centre|vignette|upright=2|Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.]] ===Le contenu d'une ligne de cache=== Dans ce qui va suivre, nous allons considérer que chaque ligne de cache mémorise son tag, les données de la ligne de cache proprement dit, et quelques bits de contrôle annexes qui varient suivant le cache considéré. [[File:Tag d'une ligne de cache.png|centre|vignette|upright=2|Tag d'une ligne de cache.]] Les caches modernes incluent de nombreux bits de contrôle, mais deux d'entre eux sont communs à presque tous les caches modernes : le bit ''Dirty'' et le bit ''Valid''. Le '''bit ''Valid''''' indique si la ligne de cache contient des données valides ou non. Si le bit ''Valid'' est à 0, la ligne de cache est en état valide, à savoir qu'elle contient des données et n'est pas vide. Par contre, si ce bit est à 1, la ligne de cache est invalide et son contenu ne peut pas être lu ou écrit. L'utilité de ce bit est qu'il permet d'effacer une ligne de cache très rapidement : il suffit de mettre ce bit à 0. Il existe des situations où le cache doit être effacé, on dit alors qu'il est invalidé. Une section de ce chapitre sera dédié à l'invalidation du cache. Le '''bit ''Dirty''''' indique qu'une ligne de cache a été modifiée. Par modifiée, on veut dire que le processeur a écrit dedans, qu'il a modifié la ligne de cache. Mais attention : si la donnée a été modifiée dans le cache, la modification n'est pas forcément propagée en mémoire RAM. Le bit ''dirty'' indique si c'est le cas, si l'écriture a été propagée en mémoire RAM. Il précise que la ligne de cache contient des données modifiées, alors que la RAM a des données initiales non-modifiées. Une ligne de cache avec un bit ''dirty'' à 1 est dite ''dirty'', par métonymie. Nous verrons cela en détail dans la section sur les caches ''write-back'' et ''write-through''. Les caches modernes ajoutent des '''bits de détection/correction d'erreur''' dans les bits de contrôle. Pour rappel, les codes de détection/correction d'erreur permettent de se prémunir contre des erreurs matérielles, qui corrompent les données stockées dans une mémoire, ici une mémoire cache. Ils ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Nous reviendrons dessus dans une section ultérieur de ce chapitre. Sur certains caches assez anciens, on pouvait transférer les lignes de caches morceaux par morceaux. Ces caches avaient des lignes de cache divisées en sous-secteurs, ces sous-secteurs étant des morceaux de ligne de cache qu'on pouvait charger indépendamment les uns des autres (mais qui sont consécutifs en RAM). Chaque secteur avait ses propres bits de contrôle, mais le tag était commun à tous les secteurs. [[File:Cache à secteurs.png|centre|vignette|upright=2.5|Cache à secteurs.]] : Dans ce qui va suivre, le terme "ligne de cache" désignera soit un bloc de données copiées depuis la RAM d'une taille de 64/128/256/... octets, soit la concaténation de ces données avec le tag et des bits de contrôle. Les deux définitions ne sont pas équivalentes, mais l'usage a entériné cet abus de langage. Et il faut avouer que cela rend les explications du chapitre plus simples. ==Les instructions de contrôle du cache== Plus haut, nous avions dit que le cache est totalement transparent du point de vue du programmeur. Le cache contient des copies de données en RAM, le programmeur n'a rien à faire pour utiliser le cache correctement. Mais la réalité est que pour des raisons diverses, des processeurs incorporent des '''instructions de contrôle du cache'''. Il s'agit d’instructions qui agissent sur le contenu du cache. Elles existent pour des raisons diverses qu'on détaillera plus bas, mais il s'agit globalement d'une question de performances ou de nécessité pour le système d'exploitation. ===Les instructions de préchargement=== La première instruction de contrôle du cache est une '''instruction de préchargement''', qui demande à charger un bloc de données dans le cache. Elle prend en opérande une adresse mémoire, et le contenu de cette adresse est chargé dans une ligne de cache. Bien sûr, des contraintes d'alignement sont à prendre en compte : on charge un bloc de la même taille qu'une ligne de cache, aligné en mémoire sur la taille du bloc, qui contient l'adresse. L'instruction de préchargement n'est utile que si l'instruction est exécutée bien avant que la donnée ne soit utilisée/lue/écrite. Cela permet de charger une donnée dans le cache à l'avance, d'où le nom de préchargement donné à cette technique. Mais les processeurs modernes gérent des techniques de préchargement automatique, qui ne requièrent pas d'instructions de préchargement. Le préchargement automatique et les instructions de préchargement sont deux solutions complémentaires, mais qui peuvent se marcher sur les pieds. Nous en reparlerons dans le prochain chapitre, qui sera dédié au préchargement automatique. Il faut noter que les instructions de préchargement peuvent être ignorées par le processeur. Sous certaines conditions, le processeur peut décider que l'instruction de préchargement ne sera pas exécutée. Par exemple, il ne va pas précharger une donnée déjà présente dans le cache. Ou encore, si le bus mémoire est occupé, il ne va pas exécuter le préchargement, par manque de ressources matérielles. ===Les instructions d'invalidation et de ''flush''=== Les instructions ''flush'' regroupent deux types d'instructions qui sont souvent utilisées en même temps. Il s'agit des instructions d'invalidation et de nettoyage (''clean''). Les deux termes proviennent de la terminologie ARM, il n'y a pas de terminologie standardisé pour les noms de ces instructions. Dans les grandes lignes, elles permettent de vider le cache, à savoir de rapatrier son contenu en RAM et de réinitialiser le cache à zéro. Elles sont utilisées par le système d'exploitation lors des commutations de contexte, à savoir quand on passe d'un programme à un autre. Elles sont aussi utilisées lors des appels systèmes et routines d'interruption/exception. L'idée est de vider le cache avant d'exécuter un nouveau programme ou une nouvelle routine. Le nouveau programme aura accès à un cache tout propre, les données de l'ancien programme auront été retirée du cache. Les '''instructions ''clean''''' recopient le contenu de la ligne de cache en RAM. Elles forcent la recopie immédiatement de la ligne de cache en mémoire RAM. Pour faire leur travail, elle vérifient si la ligne de cache a été modifiée, avant de la recopier en RAM. Et pour cela, ils vérifient le bit de contrôle ''dirty'', qui est mis à 1 après une première écriture. Si ce bit est à 0, alors pas besoin de recopier la ligne de cache : elle n'a pas été modifiée, la RAM a déjà la bonne copie. Mais s'il est à 1, le cache et la RAM n'ont pas le même contenu, la recopie s'exécute. Les '''instructions d'invalidation''' permettent d'invalider une ligne de cache, à savoir d'effacer son contenu. Nous verrons à quoi servent ces instructions dans la section sur les changement de processus. Invalider une ligne de cache est une opération optimisée : le cache n'est en réalité pas réellement effacé. À la place, le bit ''Valid'' de chaque ligne de cache est juste mis à 0. Il faut noter que l'invalidation efface les lignes de cache sans se préoccuper de leur contenu. Elle se moque qu'une ligne de cache contienne une donnée modifiée, ''dirty'' ou quoique ce soit : la ligne de cache est effacée, point. Il est possible d'invalider une ligne de cache en fournissant une adresse mémoire, mais il est aussi possible d'invalider le cache tout entier. Le choix entre les deux dépend du mode d'adressage de l'instruction d'invalidation. Parfois, il existe une instruction séparée pour invalider tout le cache, et une autre pour invalider une ligne de cache bien précise. Des instructions séparées sont parfois disponibles pour invalider les caches de données et d'instructions, parfois aussi la TLB (un cache qu'on verra dans quelques chapitres). Il est possible de n'invalider que le cache L1, voire le cache L2. Il faut noter que l'invalidation efface tout le cache, mais ne se préoccupe pas de vérifier si les données ont été modifiées dans le cache. Pour certains caches, comme le cache d'instruction, ce n'est pas un problème, vu qu'il est en "lecture seule". Mais pour les caches de données, les données modifiées sont perdues en cas d'invalidation. Heureusement, il existe des instructions d'invalidation qui fusionnent une instruction ''clean'' et une instruction d'invalidation. Il s'agit d''''instructions d'invalidation spéciales'''. ===Les instructions d'optimisation : instructions non-temporelles et écritures optimisées=== Les '''instructions mémoire non-temporelles''' contournent complètement le cache. Par exemple, une lecture peut lire une donnée, mais celle-ci ne sera pas chargée dans le cache, elle passe directement de la RAM vers les registres. Une section entière de ce chapitre sera dédiée au contournement du cache, à savoir aux situations où les accès mémoire doivent passer directement du processeur à la RAM sans passer par le cache. D'autres instructions assez rares incorporent des indications pour le cache. Par exemple, l'instruction ''load last'' des processeurs POWER PC implique que la donnée ne sera utilisée qu'une seule fois. Elle est donc chargée dans le cache, mais la ligne de cache est configurée de manière à être remplacée très rapidement, typiquement avec une valeur de LRU/LFU adéquate. La donnée est bien chargée dans le cache, au cas où elle doive être relue suite à une mauvaise prédiction de branchement ou autre, chose qu'une lecture non-temporelle (qui contourne le cache) ne fait pas. Des indications de ce type sont appelées des '''''cache hint'''''. L''''instruction ''flush''''' permet de préciser qu'une ligne de cache contient une donnée inutile, qui ne sera pas réutilisée par le programme. Pas besoin de la conserver dans le cache, elle peut laisser sa place à des données plus utiles. Or, sans indication, les algorithmes de remplacement d'une ligne de cache risquent de conserver cette donnée trop longtemps, ce qui entraine une certaine pollution du cache par des données inutiles. Une autre instruction est elle beaucoup plus importante : celle de '''pré-allocation sur écriture'''. Elle sert dans le cas où une ligne de cache est complétement écrite. Par exemple, imaginons qu'on veuille écrire dans une portion de mémoire. Si celle-ci n'est pas dans le cache, le processeur va charger une ligne de cache complète depuis la RAM, écrire dans la ligne de cache, puis recopier la ligne de cache modifiée en mémoire RAM. Une écriture en RAM demande donc de faire une lecture et une écriture. Mais les instructions de pré-allocation sur écriture permettent de prévenir qu'une ligne de cache sera intégralement écrite, et qu'il n'y a donc pas besoin de lire celle-ci depuis la RAM. Notons que l'instruction d'écriture qui suit n'est pas une écriture non-temporelle, vu que les données sont écrites dans la ligne de cache, qui est ensuite envoyée en mémoire RAM dès que nécessaire. De plus, les données écrites peuvent ensuite être relue depuis le cache si nécessaire. Enfin, certains processeurs MIPS incorporent une instruction pour modifier le tag d'une ligne de cache. Elles servent à optimiser les copies mémoire, à savoir quand on copie un bloc de données d'un endroit à un autre. L'idée est de charger le bloc de données dans le cache avec une instruction LOAD/PREFETCH, de modifier le tag pour qu'il pointe vers l'adresse à écrire, et de laisser faire le cache pour que l'écriture se fasse en RAM. Mais les contraintes pour utiliser cette instruction sont assez drastiques : les données doivent être alignées sur la taille d'une ligne de cache, le bloc de départ et d'arrivée (l'original versus la copie) ne doivent pas se recouvrir, etc. ==L'associativité des caches et leur adressage implicite== Lorsqu'on souhaite accéder au cache, il faut trouver quelle est la ligne de cache dont le tag correspond à l'adresse demandée. On peut classifier les caches selon leur stratégie de recherche de la ligne correspondante en trois types de caches : totalement associatifs, directement adressés (''direct mapped'') et associatifs par voie. ===Les caches totalement associatifs=== Avec les caches totalement associatifs, toute donnée chargée depuis la mémoire peut être placée dans n'importe quelle ligne de cache, sans aucune restriction. Ces caches ont un taux de succès très élevé, quand on les compare aux autres caches. [[File:Cache totalement associatif.png|centre|vignette|upright=2|Cache totalement associatif.]] Concevoir un cache totalement associatif peut se faire de deux grandes manières différentes. La première consiste tout simplement à combiner une mémoire associative avec une mémoire RAM, en ajoutant éventuellement quelques circuits annexes. La mémoire associative mémorise les tags, alors que la mémoire RAM mémorise les données de la ligne de cache, éventuellement avec quelques bits de contrôle. La ligne de cache est stockée à une adresse A dans la mémoire RAM et son tag est stocké à la même adresse, mais dans la mémoire CAM. Ce faisant, quand on envoie le tag à la mémoire CAM, elle renvoie l'adresse de la ligne de cache dans la mémoire RAM. Cette adresse est alors envoyée directement sur le bus d'adresse de la RAM, et la lecture est effectuée automatiquement. Il faut ajouter quelques circuits annexes pour garantir que les écritures se passent correctement dans les deux mémoires, mais rien de bien terrible. [[File:Cache fabriqué avec une mémoire associative et une RAM.png|centre|vignette|upright=3|Cache fabriqué avec une mémoire associative et une RAM]] Il est cependant possible d'optimiser un tel cache, en fusionnant la mémoire CAM et la mémoire RAM, afin d'éliminer des circuits redondants. Pour comprendre pourquoi, rappelons que les mémoires CAM sont composées d'un plan mémoire, d'un paquet de comparateurs et d'un encodeur. Quant à la mémoire RAM, elle est composée d'un décodeur connecté au plan mémoire. En mettant une CAM suivie d'une RAM, on a un encodeur dont l'entrée est envoyée à un décodeur. [[File:Cache totalement associatif naif.png|centre|vignette|upright=3|Cache totalement associatif naif]] Or, le décodeur réalise l'opération inverse de l'encodeur, ce qui fait que mettre les deux composants à la suite ne sert à rien. On peut donc retirer l'encodeur et le décodeur, et envoyer directement les résultats des comparateurs sur les entrées de commande du plan mémoire de la RAM. [[File:Cache totalement associatif optimisé.png|centre|vignette|upright=2|Cache totalement associatif optimisé]] Avec cette méthode, les circuits du cache ressemblent à ce qui illustré ci-dessous. Le tag est envoyé à chaque ligne de cache. Le tag envoyé est alors comparé avec le Tag contenu dans chaque ligne de cache, comme c'est le cas sur les mémoires associatives. Si une ligne de cache matche avec le tag envoyé en entrée, la ligne pour laquelle il y a eu une égalité est alors connectée sur les lignes de bit (''bitlines''). Cela est réalisé par un circuit commandé par le comparateur de la ligne de cache. Il ne reste plus qu'à sélectionner la portion de la ligne de cache qui nous intéresse, grâce à un paquet de multiplexeurs. Cela permet d'effectuer une lecture ou écriture, mais il faut aussi préciser si il y a eu un défaut de cache ou un succès. Un succès de cache a lieu quand au moins des comparaisons est positive, alors que c'est un défaut de cache sinon. En clair, détecter un succès de cache demande juste de connecter une porte OU à plusieurs entrées à tous les comparateurs. [[File:Organisation générale d'un cache totalement associatif.png|centre|vignette|upright=2|Organisation générale d'un cache totalement associatif.]] ===Les caches directement adressés=== Les caches directement adressés peuvent être vus comme un cache totalement associatif auquel on aurait ajouté des restrictions assez drastiques. Plus haut, on a vu qu'un cache totalement adressé est équivalent à la combinaison d'une CAM avec une RAM. La mémoire CAM prend en entrée un Tag et traduit celui-ci en une adresse qui commande la mémoire RAM interne au cache. Dans ce qui suit, l'adresse interne au cache sera appelé l''''indice''' pour éviter toute confusion. [[File:Cache hash table - 2.png|centre|vignette|upright=2|Fonctionnement interne du cache, expliquée sous forme abstraite, en utilisant la notion d'indice interne au cache.]] Les caches directement adressés cherchent à remplacer la mémoire CAM par un circuit combinatoire. Ce circuit traduit le Tag en indice, mais est beaucoup plus simple qu'une mémoire CAM. Mais qui dit circuit plus simple dit circuit plus limité. Un circuit combinatoire n'est pas aussi versatile que ce qui est permis avec une mémoire CAM. En conséquence, une restriction majeure apparait : toute adresse mémoire est associée dans une ligne de cache prédéfinie, toujours la même. L'association entre ligne de cache et adresse mémoire est faite par le circuit combinatoire, et ne peut pas changer. Les concepteurs de caches s'arrangent pour que des adresses consécutives en mémoire RAM occupent des lignes de cache consécutives, par souci de simplicité. Tout se passe comme suit la mémoire RAM était découpés en blocs de la même taille que le cache. La première adresse du bloc est associée à la première ligne de cache (celle d'indice 0), la seconde adresse est associée à la seconde adresse du_ bloc, et ainsi de suite. Le tout est illustré ci-dessous. [[File:Cache adressé directement.png|centre|vignette|upright=2|Cache adressé directement.]] Avec cette contrainte, le circuit de traduction de l'adresse en adresse mémoire pour la RAM interne au cache est drastiquement simplifié, et disparait même. Une partie de l'adresse mémoire sert à indiquer la position de la donnée dans le cache, le reste de l'adresse sert encode le tag et la position de la donnée dans le ligne de cache. [[File:Cache line.png|centre|vignette|upright=2|Adresse d'une ligne de cache sur un cache adressé directement.]] Un cache directement adressé est conçu avec une RAM, un comparateur, et un paquet de multiplexeurs. En général, la mémoire RAM stocke les lignes de caches complète. Il arrive que l'on utilise deux mémoires RAM : une pour les tags et une pour les données, mais cette technique augmente le nombre de circuits et de portes logiques nécessaires, ce qui réduit la capacité du cache. L'index à lire/écrire est envoyé sur l'entrée d'adresse de la RAM, la RAM réagit en mettant la ligne de cache sur sa sortie de donnée. Sur cette sortie, un comparateur compare le tag de la ligne de cache lue avec le tag de l'adresse à lire ou écrire. On saura alors si on doit faire face à un défaut de cache. Ensuite, un multiplexeur récupère la donnée à lire/écrire. [[File:Direct mapped cache - french.png|centre|vignette|upright=2|Cache directement adressé.]] L'accès à un cache directement adressé a l'avantage d'être très rapide vu qu'il suffit de vérifier une seule ligne de cache : celle prédéfinie. Mais ces caches ne sont cependant pas sans défauts. Vu que le cache est plus petit que la mémoire, certaines adresses mémoires se partagent la même ligne de cache. Si le processeur a besoin d’accéder fréquemment à ces adresses, chaque accès à une adresse supprimera l'autre du cache : tout accès à l'ancienne adresse se soldera par un défaut de cache. Ce genre de défauts de cache causés par le fait que deux adresses mémoires ne peuvent utiliser la même ligne de cache s'appelle un '''défaut par conflit''' (''conflict miss''). Les défauts par conflit n'existent pas sur les caches totalement associatifs. En conséquence, le taux de succès des caches directement adressés est assez faible comparé aux autres caches. [[File:Cache Block Basic Conflict.svg|centre|vignette|upright=1.5|Exemple de ''Conflict Miss''.]] ===Les caches associatifs par voie=== Les caches associatifs par voie sont un compromis entre les caches directement adressés et les caches totalement associatifs. Pour simplifier, ces caches sont composés de plusieurs caches directement adressés accessibles en parallèle, chaque cache/RAM étant appelé une '''voie'''. Avec ces caches, toute adresse mémoire en RAM est associée à une ligne de cache dans chaque voie. [[File:Cache associatif par voie.png|centre|vignette|upright=2|Cache associatif par voie.]] Le schéma ci-dessous compare un cache directement adressé et un cache associatif à deux voies. On voit que chaque adresse est associée à une ligne de cache bien précise avec un cache directement dressé, et à deux lignes de cache avec un cache associatif à deux voies. L'adresse sera associée à 4 lignes de cache sur un cache associatif à 4 voies, à 8 lignes pour un cache à 8 voies, etc. L'ensemble des lignes de cache associées à une adresse est appelé un '''ensemble'''. [[File:Cache Fill.svg|centre|vignette|upright=2|Comparaison entre un cache directement adressé et un cache associatif à deux voies.]] Sur ces caches, toute adresse est découpée en trois parties : un tag, un index, et un décalage, comme sur les caches directement adressés. Comme vous pouvez le voir, l'organisation est identique à celle d'un cache totalement associatif, à part que chaque ensemble tag-ligne de cache est remplacé par une mémoire RAM qui en contient plusieurs. [[File:Implémentation d'un cache associatif par voie.png|centre|vignette|upright=2|Implémentation d'un cache associatif par voie.]] Le risque de conflits d'accès au cache est donc réduit sur un cache associatif à plusieurs voies, et il est d'autant plus réduit que le cache a de voies. Par contre, leur conception interne fait qu'ils ont un temps d'accès légèrement élevé que les caches directement adressés. Les caches associatifs par voie ont donc un taux de succès et un temps d'accès intermédiaire, situé entre les caches directement adressés et totalement associatifs. Ils sont une sorte de compromis entre réduction des défaut par conflits d'accès au cache et temps d'accès, et complexité des circuits. ==Les optimisations des caches associatifs par voie== Les caches partiellement associatifs regroupent les caches associatifs par voie et directement adressés, ainsi que leurs variantes. En clair : tous les caches qui ne sont pas totalement associatifs. Ils peuvent être optimisés de nombreuses manières, que ce soit pour gagner en performance ou pour économiser de l’énergie. Dans cette section, nous allons voir quelles sont ces optimisations. ===Les caches pseudo-associatifs=== Les caches adressés par voie contiennent une mémoire SRAM par voie. En théorie, les voies sont accédées en parallèles, en même temps, afin de voir si l'on a un succès de cache ou un défaut. Les '''caches pseudo-associatifs''' sont identiques aux caches associatifs par voie, si ce n'est qu'ils vérifient chaque voie une par une. Ils ont été utilisés sur des processeurs commerciaux, un exemple étant l'IBM 370. Là encore, on perd en performance pour gagner en consommation d'énergie. Le temps d'accès dans le meilleur des cas est plus faible pour les caches pseudo-associatifs, mais le pire des cas teste tous les caches avant de tomber sur le bon. Les performances sont donc réduites. Mais la consommation énergétique est meilleure, vu qu'on ne vérifie pas forcément toutes les voies en parallèle. On teste la première voie, éventuellement la seconde, peut-être la troisième, etc. Mais dans le cas général, on ne teste qu'une partie des voies, pas toutes, ce qui donne un gain en termes d'énergie. L'implémentation de caches de ce genre demande que l'on parcoure les voies une par une, en commençant de la première jusqu'à la dernière. Pour cela, un simple compteur suffit. Suivant la valeur du compteur, la voie associée est activée puis accédée. Toute la complexité revient à ajouter un circuit qui prend la valeur du compteur, et active la voie associée, lance un accès mémoire dessus. Vu que les voies sont chacune des caches ''direct mapped'', il suffit pour cela de geler les entrées d'adresse, soit en les déconnectant, soit en utilisant du ''clock gating'' ou de l'évaluation gardée. Les détails d'implémentation, non-cités ici, varient selon le cache. ===La prédiction de voie=== Pour réduire le temps d'accès des caches pseudo-associatifs, certains chercheurs ont inventé la '''prédiction de voie''', qui consiste à faire des paris sur la prochaine voie accédée. L'idée est d'accéder à la voie qui contient la donnée voulue du premier coup, en lisant celle-ci en priorité. Dans son implémentation la plus simple, le cache reste un cache pseudo-associatif. Lors d'un accès au cache, les voies sont toutes parcoures une par une. Par contre, les voies ne sont donc pas parcourues de la première vers la dernière, mais dans un ordre différent. Cette technique permet de mettre en veille les voies sur lesquels le processeur n'a pas parié, ce qui permet de diminuer la consommation énergétique du processeur. C'est plus efficace que d'aller lire plusieurs données dans des voies différentes et de n'en garder qu'une. L'implémentation est assez simple : il suffit d'ajouter un circuit de prédiction de voie,relié au compteur de voie. Une amélioration de la technique fait fonctionner le cache comme un intermédiaire entre cache pseudo-associatif et associatif par voies. L'idée est de chercher la voie prédite en premier, puis de chercher dans toutes les voies en parallèle en cas de défaut de cache. Au lieu d'attendre que les comparaisons de tags donnent leur résultat, le processeur sélectionne automatiquement une voie et configure les multiplexeurs à l'avance. Si le processeur ne se trompe pas, le processeur accède à la donnée plus tôt que prévu. S'il se trompe, le processeur annule la lecture effectuée en avance et recommence en faisant un accès en parallèle aux autres voies. Le compromis entre performance et consommation d'énergie est alors différent. On économise de l'énergie par rapport à un cache associatif par voie, au prix d'une petite perte de performance (doublement des temps d'accès). Mais par rapport à un cache pseudo-associatif, l'économie d'énergie est bien moindre, au prix d'un gain en performance assez manifeste. Prédire quelle voie sera la bonne est assez simple. En vertu du principe de localité, les accès futurs ont des chances de tomber dans les voies les plus fréquemment utilisées ou dans celle plus récemment utilisée. Il suffit de retenir la voie la plus récemment accédée dans un registre, qui sera utilisée comme prédiction. Pour vérifier que la prédiction est correcte, il suffit de comparer le registre et le résultat obtenu après vérification des tags. Cependant, on peut complexifier l'implémentation pour prendre en compte l'adresse à lire/écrire, l'instruction à l'origine de l'accès mémoire ou tout autre paramètre utile. Par exemple, des instructions différentes ont tendance à aller chercher leurs données dans des ensembles différents et la voie à choisir n'est pas la même. Pour cela, il suffit d'utiliser un cache pour stocker la correspondance instruction - voie. Pour plus de simplicité, la mémoire cache des prédictions est parfois remplacée par une RAM, qui est adressée : * soit par le program counter de l'instruction à l'origine de l'accès (en réalité, seulement quelques bits de poids faible de l'adresse) ; * soit par l'adresse à accéder (là encore, quelques bits de poids faible) ; * soit (pour les modes d'adressage qui utilisent un registre de base et un décalage) par un XOR entre les bits de poids faible de l'adresse de base et le décalage ; * soit par autre chose. ===La mise en veille sélective des voies=== Les caches associatifs ont tendance à utiliser beaucoup d'énergie, même quand on n'y accède pas. Aussi, certains processeurs détectent quand le cache est peu utilisé et en profitent pour mettre en veille les voies inutilisées. Vous vous demandez certainement ce qui se passe quand une donnée à lire/écrire est dans une voie désactivée. La réponse est que le cache détecte cette situation, car elle déclenche un succès de cache. Les ''tags'' ne sont en effet pas désactivés, seules les données sont mises en veille. L'implémentation est plus simple sur les caches qui séparent les tags et les données dans deux RAM différentes. Cette optimisation marche surtout sur les gros caches, qui ont des chances d'avoir une portion significative d’inutilisée (pas assez de données pour les remplir), donc généralement les caches L3/L4. Par exemple, les processeurs d'Intel de microarchitecture Ivy Bridge disposent d'un cache de 8 mébioctets à 16 voies, qu'ils peuvent faire passer à 512 kibioctets si le besoin s'en fait sentir. Quand ces processeurs détectent une faible activité, ils mettent en veille 14 voies et n'en gardent que 2 d'actives. Évidemment, les 14 voies sont vidées avant d'être mises en veille, afin qu'une aucune donnée ne soit perdue. ===Les caches ''skew-associative''=== Vous aurez remarqué que dans une voie, les lignes sont accédées en adressage direct : les défauts par conflit sont possibles sur un cache associatif par voie. Pour éviter cela, certains chercheurs ont créé des '''caches ''skew associative''''' (ou associatifs à biais). Pour faire simple, les index des lignes de cache subissent un petit traitement avant d'être utilisés. Le traitement en question est différent suivant la voie de destination, histoire que deux adresses mémoires avec des index identiques donnent des index différents après traitement. Le traitement en question est souvent une permutation des bits de l'index, qui est différente suivant la voie prise, ou un simple XOR avec un nombre qui dépend de la voie. [[File:Implémentation d'un cache skew associative.jpg|centre|vignette|upright=2|Implémentation d'un cache skew associative.]] ==Les caches splittés (''phased caches'')== Dans cette section, nous allons voir les '''caches splittés''' (''phased caches''), qui sont une variante des caches ''direct-mapped'', dans lequel le cache est accédé en deux étapes consécutives. Il ne s'agit pas des caches pipelinés, que nous verrons dans le chapitre sur les processeurs pipélinés, mais laissons cela à plus tard. Il est possible d'appliquer la même méthode sur un cache associatif par voie, mais il y a des méthodes plus simples, qui permettent là aussi d’accéder au cache en plusieurs étapes consécutives. L'idée est de scinder le cache en deux : une mémoire pour les tags, une autre pour les données de la ligne de cache. Les bits de contrôle peuvent être mis dans l'une ou l'autre SRAM, mais ils sont souvent mis dans la RAM pour les tags. En faisant cela, quelques optimisations deviennent possibles, afin de réduire la consommation énergétique en contrepartie d'une perte de performance. La technique s'implémente différemment pour les caches totalement associatifs et partiellement associatifs. Les caches totalement associatifs splittés sont ceux formés en combinant un cache associatif avec une CAM et une RAM combinée. On envoie l'adresse à lire/écrire à la mémoire associative, elle répond en envoyant une adresse à la mémoire RAM. L'accès se fait donc en deux temps, avec l'adresse dans la RAM comme intermédiaire. Il est possible de séparer physiquement les deux étapes en insérant un registre entre la CAM et la RAM, ce qui permet aussi de pipeliner l'accès. Mais c'est rarement fait en pratique, car le cout en circuit d'une mémoire CAM est trop important. L'équivalent pour un cache totalement associatif optimisé, sans CAM et RAM séparée, est trop gourmande en interconnexions pour être implémentée. Les caches totalement associatifs splittés sont donc très rares, l'auteur ne connait aucun exemple de processeur avec un tel cache. Il existe une technique équivalente pour les caches ''direct-mapped'', mais elle demande une certaine modification du cache. Dans les caches ''direct-mapped'' non-splittés, on trouve une mémoire SRAM dont chaque mot mémoire contient une ligne de cache entière, tag inclus. Dans leurs versions splittés, la SRAM est séparée en deux : une pour les tags, une autre pour les données. Précisons qu'il s'agit bien de deux mémoires SRAM adressables. L'adresse à laquelle accéder est envoyée à la SRAM des tags, puis ensuite à la SRAM des données si besoin. L'idée est d’accéder aux tags pour déterminer s'il y a un succès de cache ou un défaut, et ensuite d'accéder aux données. On n’accède pas aux données en parallèle des tags. Faire cela est évidemment plus lent. En cas de défaut de cache, le temps d'accès est similaire : le tag ne correspond pas, on n'accède pas à la SRAM pour les données. Par contre, vu qu'on n'a pas activé la SRAM pour les données, on économise un peu d'énergie, ce qui réduit la consommation d'énergie. En cas de succès de cache, on accède à la SRAM pour les tags, puis à celle pour les données. Pas d'économie d'énergie à l'horizon, sans compter que le temps d'accès augmente : on accède au cache en deux étapes au lieu de faire les deux accès en parallèle. [[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]] Précisons cependant que ce design peut avoir deux avantages en termes de performance. Premièrement, le temps d'accès au cache est légèrement amélioré en cas de défaut de cache. En effet, la SRAM des tags est assez petite, idem pour celle des données. Leur temps d'accès est donc plus faible que pour une grosse SRAM contenant données et tags. Le gain en temps d'accès est donc un avantage, qui ne se manifeste surtout en cas de défaut de cache. Un autre avantage est que l'accès au cache se pipeline plus facilement, ce qui fait qu'on peut effectuer plusieurs accès simultanés au cache. Mais nous verrons cela dans quelques chapitres. ===Le contrôleur de cache 82385 pour les CPU Intel 386=== Il est important de noter que la séparation entre tags et RAM peut être telle que les deux ne sont pas sur la même puce de silicium ! Voire que les deux sont séparés du processeur ! C'était le cas quand les mémoires caches ont été introduites sur les processeurs grand public, notamment sur les premiers processeurs Intel. La miniaturisation n'avait pas avancé au point où placer un cache dans le processeur était possible. Sur le processeur 386 d'Intel, le cache était un cache splitté, séparé du processeur. Concrètement, le processeur i386 était couplé à un contrôleur de cache Intel 82385 et une mémoire SRAM. Le 82385 contenait les ''tags'' et les bits de contrôle, la SRAM contenait les données, les lignes de cache. Un point important est que les lignes de cache faisaient seulement 32 bits/4 octets, pas plus ! On était loin des lignes de cache actuelles, faisant 64 octets/512 bits. Mais c'était beaucoup plus pratique, vu que le bus système faisait 32 bits de large, idem pour l'interface avec le processeur. Pour intégrer un cache facultatifs (certains ordinateurs 'en passaient). Le schéma ci-dessous montre comment le cache s'intégrait avec le bus système. Pour le bus de commande, le cache servait d'intermédiaire : il recevait les commandes et et les filtrait suivant les succès/défauts de cache. En cas de succès de cache, les commandes de lecture n'étaient pas envoyées à la mémoire RAM. Les adresses étaient transmises à la fois au cache et au bus système (avec un registre entre le bus système et le processeur). Le bus de donnée était lui connecté à la mémoire SRAM et au processeur, avec des MUX/DEMUX pour faire le choix de la source des lectures. [[File:Controleur de cache 82385 pour l'Intel 386.png|centre|vignette|upright=2.5|Contrôleur de cache 82385 pour l'Intel 386]] Le 82385 surveillait ce qui se passait sur le bus et répondait à la place de la RAM pour certaines lectures. C'était un intermédiaire assez passif, qui se contenait de répondre aux succès et défauts en lecture. Le cache était un cache ''write through'' un peu particulier. En cas de succès de cache pour une écriture, le cache met à jour sa ligne de cache et propage l'écriture en mémoire RAM. Par contre, si une écriture fait un défaut de cache, la donnée n'est pas écrite dans le cache. Le seul moyen pour copier une donnée dans le cache était un défaut pour une lecture. Le 82385 pouvait commander soit un cache ''direct mapped'', soit associatif à deux voies. Le choix entre les deux était le fait d'une entrée : la mettre à 0 indiquait un cache ''direct mapped'', la mettre à 1 forçait un cache à deux voies. La différence entre les deux est que le 82385 était relié à une mémoire SRAM avec un cache ''direct mapped'', deux SRAM pour deux voies. Pour avoir un cache associatif à deux voies, le 82385 devrait gérer deux signaux ''chip select'' pour activer chaque SRAM/voie suivant les besoins. Il avait précisément quatre signaux CS : deux par SRAM, un pour les lectures, un pour les écritures. Notons que les lignes de cache faisaient 32 bits, ce qui pouvait d'obtenir soit avec une SRAM 32 bits, soit avec deux SRAM 16 bits, soit avec 4 SRAM 8 bits. Le 82385 rajoutait 4 sorties, pour masquer chaque octet dans ces 32 bits, qui sont techniquement des signaux ''Output Enable'' pour 4 SRAM 8 bits. [[File:Interface entre le 82385 et la SRAM du cache.png|centre|vignette|upright=2|Interface entre le 82385 et la SRAM du cache. Beaucoup d'entrées et de sorties liées au bus d'adresse ne sont pas représentées.]] Il gérait aussi les accès mémoire non-cacheable, à savoir des accès mémoire qui ne doivent pas être pris en compte par le cache. Il considérait certains accès mémoire comme "à ne pas cacher". Notamment, les accès mémoire à une entré-esortie ne sont pas cachés. Pour rappel, le processeur utilisait un espace d'adressage séparé pour les entrées-sorties, et utilisait donc un bit IO, qui était utilisé par le 82385 pour savoir si l'accès mémoire doit être caché ou non. Il en est de même pour les accès ayant lieu lors d'une interruption, qui ne passent pas par le cache. Mais au-delà de cette inhibition automatique du cache, le 82385 avait une entrée NCA (''Non Cacheable Access'') : le cache était "désactivé" quand cette entrée était à 1. C'est un peu une sorte de ''chip select'' pour le 82385, limitée aux accès mémoire. Cette entrée permettait de programmer des intervalles d'adresse auxquels ne pas répondre, en utilisant des circuits de décodage d'adresse adaptés. Il avait aussi une entrée X16, qui permettait d'identifier les accès soit à un composant 16 bits. De tels accès ne doivent pas être mis en cache, sans doute parce que cela ne collait pas avec la taille des lignes de cache (32 bits). Et cette entrée permettait d'inhiber ces accès 16 bits d'agir sur le cache, en utilisant le bit du bus de commande adéquat. Le 82385 pouvait être intégré dans un système à deux processeurs, voire plus. Pour cela, chaque processeur avait son propre 82385 et sa SRAM rien qu'à lui. Il n'y avait pas de cache partagé entre les deux processeurs. Par contre, les deux caches étaient reliés au même bus système. Pour qu'ils ne se marchent pas sur les pieds, il y avait des circuits d'arbitrage pour gérer l'accès au bus. Un des deux 82385 était mis en mode maitre, l'autre était en mode esclave. Le 82385 maitre pouvait prendre le contrôle du bus, le 82385 esclave devait demander l'autorisation au premier pour accéder au bus système. Le 82385 gérait une forme limitée de cohérence des caches par invalidation. Dès que le 82385 détectait une prise de contrôle du bus par autre chose que le processeur, il surveillait les adresses transmises sur le bus. En cas de succès de cache, la ligne de cache associée était invalidée. Au-delà de ca, le 82385 avait une entrée FLUSH, qui ordonnait une invalidation totale du cache. Si cette entrée est mise à 1, toutes les lignes de cache sont invalidées. Les ''tags'' sont marqués comme invalides, mais les lignes de cache elles-mêmes ne sont pas touchées. ===L'exemple des processeurs Intel de microarchitecture ''Broadwell''=== Un autre exemple est celui du cache L4 des processeurs Broadwell et de quelques processeurs séparés. Ces processeurs ont une organisation en ''chiplet'' où le processeur incorpore plusieurs puces séparées : une puce pour le processeur proprement dit, une puce nommée ''Crystal Well'' pour le cache L4, et une puce IO pour la communication avec la RAM et la carte mère. Le processeur incorporait un cache L4 de 128 mébioctets, composé de mémoire eDRAM, qui était dispersé entre ''Crystal Well'' et les autres puces. Les données du cache L4 étaient dans ''Crystal Well'', alors que les Tags étaient soit dans le processeur lui-même, soit dans la puce IO ! La puce ''Crystal Well'' était une mémoire DRAM adressable tout ce qu'il y a de plus basique, avec cependant quelques optimisations notables. Par exemple, elle avait deux bus séparés pour l'écriture et la lecture. De plus, elle avait une organisation interne avec 128 banques, contre moins d'une dizaine pour la DDR de l'époque et environ 32 banques pour la DDR5 moderne. Elle contenait aussi quelques circuits pour gérer son rôle de mémoire cache, mais rien en ce qui concerne la gestion des tags eux-mêmes. Sur les processeurs de microarchitecture ''Broadwell'', les tags étaient placés dans le CPU et précisément dans le cache L3. À chaque accès mémoire au cache L3, les tags du cache L4 étaient consultés en parallèle. De fait, l'accès au cache L4 était assez rapide, malgré le fait que les données étaient dans une puce à part. Ajoutons à cela que le processeur et ''Crystal Well'' n'avaient pas la même finesse de gravure ni la même technologie de fabrication. Les tags étaient implémentés avec de la SRAM contre la DRAM pour les données, ce qui fait que la consultation des tags était plus rapide que l'accès aux données. Par la suite, dans certains CPU de microarchitecture ''skylake'', les tags ont été déplacés en-dehors du processeur pour finir dans le contrôleur mémoire. En faisant cela, le cache L4 pouvait être utilisé par autre chose que le processeur, et notamment par la carte graphique intégrée au CPU. Avec ''broadwell'', le fait que les tags étaient consultés en cas d'accès au L3 empêchait au GPU intégré de consulter le cache L4. Mais en déplaçant les tags dans le contrôleur mémoire, ce n'est plus le cas vu que la carte graphique a aussi accès au bus mémoire. Par contre, le temps d'accès augmente comparé à la solution précédente. On n'accède pas aux tags du L4 en parallèle du L3 : à la place, il faut consulter les tags du L3, détecter un défaut de cache L3, et ensuite accèder aux tags. ===Les caches RAM-configurables=== Un autre avantage des caches splittés est qu'on peut les modifier pour servir à la fois de mémoire cache, mais aussi de ''local store'', de mémoire RAM de petite taille. Le fonctionnement est assez simple à comprendre. Lors d'un accès au cache, on accède aux tags, puis à la RAM interne au cache. Lors d'un accès au ''local store'', on contourne l'accès au tags et on accède à la RAM interne au cache directement. Il s'agit de la technique du '''cache RAM-configurable''. L'usage de cache RAM-configurable est fréquent sur les cartes graphiques récentes, qui incorporent un ou plusieurs processeurs multicoeurs, dont le cache L1 de données est un cache RAM-configurable. [[File:Hydride cache - local store.png|centre|vignette|upright=2.0|Hydride cache - local store]] ===La compression de cache=== Une autre optimisation permise par les ''phased caches'' est l'implémentation de techniques de '''compression de cache''', qui visent à compresser des lignes de cache. L'intérêt est qu'on peut stocker plus de données dans le cache, à capacité égale. L'inconvénient est qu'on doit compresser/décompresser les lignes de cache, ce qui demande un circuit en plus et allonge les temps d'accès. En effet, le temps mis pour compresser/décompresser une ligne de cache s'ajoute au temps d'accès. Aussi, la compression de cache sert surtout pour les caches de bas niveau dans la hiérarchie mémoire, les gros caches aux temps d'accès assez longs. Une première technique, assez simple à implémenter et peu couteuse en circuit, est celle de la '''compression des lignes de cache nulles'''. Elle compresse uniquement les lignes de cache qui ne contiennent que des zéros. L'idée est qu'on ajoute, dans la mémoire des tags, un bit de contrôle pour chaque ligne de cache appelé le bit ''null''. Il indique si la ligne de cache ne contient que des zéros. Quand on lit une ligne de cache, la mémoire des tags est accédée et on vérifie le bit ''null'' : s'il vaut 1, on n'accède pas à la mémoire cache de données et un multiplexeur envoie un zéro sur le port de lecture. Le bit ''null'' est fixé lors de l'écriture d'une ligne de cache : elle passe dans un comparateur avec zéro relié à la mémoire des tags. La comparaison avec zéro peut se faire en parallèle de l'écriture ou avant (dans ce cas, on n'écrit pas la ligne de cache dans le cache). Les autres techniques de compression de cache permettent de compresser autre chose que des lignes de cache nulles. L'idée est qu'une ligne de cache physique peut par moment mémoriser plusieurs lignes de caches compressées. Par exemple, prenons un cache dont les lignes de cache font 64 octets. Il est possible de compresser deux lignes de cache pour qu'elles fassent chacune 32 octets, et les stocker dans une seule ligne de cache. Les deux lignes de cache auront des tags différents, mais pointeront sur la même ligne de cache physique. Et cela demande d'utiliser un ''phased cache'' dont la mémoire pour les tags est plus grande que la mémoire pour les données. Il n'y a donc plus une bijection entre tags et ligne de cache, mais une relation surjective. Chose qui n'est possible qu'avec un ''phased cache''. De plus, des bits de contrôles associés à chaque ''tag'' indiquent où se trouvent les lignes de cache compressées dans la ligne de cache : est-ce que c'est les 32 octets de poids fort ou de poids faible ? [[File:Compression de cache.png|centre|vignette|upright=2|Compression de cache]] Il ne semble pas que les techniques de compression de cache soient implémentées sur les processeurs modernes. Aucun n'utilise de compression de cache, à ma connaissance. Il faut dire que les techniques connues sont de mauvais compromis : le temps d'accès du cache augmente beaucoup, le cout en circuit pourrait être utilisé pour un cache non-compressé mais plus grand. Et notons que la compression de cache ne marche que si les données peuvent se compresser. Si ce n'est pas le cas, une partie de la mémoire des tags est inutilisée. Une revue de la littérature académique sur la compression de cache est disponible via ce lien, pour les curieux : * [https://inria.hal.science/hal-03285041 Understanding Cache Compression, par Carvalho et Seznec]. ==L'adressage physique ou logique des caches== Le cache utilise les adresses à lire/écrire pour déterminer s'il a une copie de la donnée en son sein. Mais l’interaction entre caches et mémoire virtuelle donne lieu à un petit problème : l'adresse utilisée est-elle une adresse virtuelle/logique ou physique ? La réponse varie suivant le processeur : certains caches utilisent l'adresse virtuelle, tandis que d'autres prennent l'adresse physique. On parle de cache '''virtuellement tagué''' dans le premier cas et de cache '''physiquement tagué''' dans le second. {| |[[File:Cache tagué virtuellement.png|vignette|Cache tagué virtuellement.]] |[[File:Cache tagué physiquement.png|vignette|Cache tagué physiquement.]] |} ===L'accès à un cache physiquement/virtuellement tagué=== La manière d'accéder à un cache dépend de s'il est virtuellement ou physiquement tagué. Il faut utiliser l'adresse virtuelle pour les premiers, physique pour les seconds. Avec un cache virtuellement tagué, l'adresse logique peut être envoyée directement au cache. La MMU ne traduit les adresses que s'il faut accéder à la mémoire RAM. Ces caches sont donc plus rapides. Avec un cache physiquement tagué, le processeur doit traduire l'adresse logique en adresse physique dans la MMU, avant d'accéder au cache. La traduction d'adresse se fait soit en accédant à une table des pages en mémoire RAM, soit en accédant à un cache spécifiquement dédié à accélérer la traduction d'adresse, la TLB (''Translation Lookaside Buffer''). Dans la quasi-totalité des cas, la traduction d'adresse passe par la TLB, ce qui fait qu'elle est raisonnablement rapide. Toujours est-il que chaque accès au cache demande d'accéder à la TLB et de faire la traduction d'adresse avant d'accéder au cache. L'accès est donc plus lent que sur les caches virtuellement tagués, où les accès sont plus directs. [[File:Virtual and Physical addressing.svg|centre|vignette|upright=2|Cache tagué virtuellement versus physiquement tagué.]] ===Les défauts des caches virtuellement tagués=== Les caches physiquement tagués sont moins rapides que les caches virtuellement adressés. Pourtant, les caches virtuellement tagués sont peu fréquents sur les processeurs modernes. Et la raison est assez intéressante : c'est une question d'adresses homonymes et synonymes. ====Les droits d'accès doivent être vérifiés lors d'un accès au cache==== Un premier problème est que la protection mémoire est compliquée avec de tels caches. Rappelons que certaines portions de mémoire sont accessibles seulement en lecture, ou sont interdites en écriture, sont inexécutables, etc. Ces droits d'accès sont gérés par la MMU, qui vérifie pour chaque accès mémoire que l'accès est autorisé. En bypassant la MMU, l'accès au cache virtuellement tagué ne permet pas de faire ces vérifications. Il est possible de charger une donnée en lecture seule dans le cache, mais d'y faire des accès en écriture pour les accès ultérieurs. Les solutions à cela sont multiples. La première consiste à consulter la MMU en parallèle de l'accès au cache. L'accès au cache est alors réalisé de manière spéculative, et est ensuite confirmé/annulé une fois que la MMU a rendu son verdict. Les performances du cache restent alors les mêmes : l'accès à la MMU se fait en parallèle de l'accès au cache, pas avant. Une autre solution est d'ajouter les droits d'accès en question dans la ligne de cache, dans les bits de contrôle situés après le Tag. Chaque accès au cache récupère ces bits de contrôle et vérifie si l'accès est autorisé. L'inconvénient est que les lignes de cache deviennent plus longues, les droits d'accès sont dupliqués entre MMU et cache. Mais si le budget en transistor suit, ce n'est rien d'insurmontable. ====Les adresses homonymes perturbent la gestion du cache==== Pour rappel, une adresse logique homonyme correspond à plusieurs adresses physiques différentes. Elles surviennent quand chaque programme a son propre espace d'adressage. Dans ce cas, une adresse logique correspondra à une adresse physique différente par programme.Une autre manière de voir les choses est qu'il y a en réalité deux adresses homonymes, qui ont la même valeur, mais appartiennent à des espaces d'adressage différentes. Et c'est cette seconde interprétation que nous allons utiliser. Les caches doivent gérer ces adresses homonymes et faire en sorte que la lecture/écriture d'une adresse homonyme se fasse à la bonne adresse physique, dans la bonne ligne de cache. Et autant un cache physiquement tagué n'a aucun problème avec ça, vu qu'il ne gère que des adresses physiques, autant des problèmes surviennent avec les caches virtuellement tagués. Le problème est que les caches virtuellement tagués doivent faire la différence entre deux adresses homonymes de même valeur. Pour corriger ces problèmes, il existe deux grandes méthodes. La première méthode est simple : '''vider les caches''' en changeant de programme. Leur contenu est rapatrié en mémoire RAM, puis les caches sont remis à zéro. Le vidage du cache recopie les lignes de cache ''dirty'' (modifiées) en RAM, puis efface/invalide tout le cache. C'est à cela que servent les instructions ''clean'' et d'invalidation vues plus haut, elles ont été inventées pour cette situation précise. Lorsque le système d'exploitation déclenche une commutation de contexte, à savoir qu'il change le programme en cours d'exécution, le processeur vide tous les caches du processeur. Les interruptions font la même chose, elles vide tous les caches du processeur. Une seconde méthode numérote chaque programme en cours d'exécution, chaque processus. Le numéro attribué est spécifique à chaque processus, ce qui fait qu'il est appelé un '''identifiant de processus CPU'''. Le processeur mémorise l'identifiant du programme en cours d'exécution dans un registre dédié. L'identifiant de processus CPU est utilisé lors des accès mémoire. Chaque ligne de cache contient le numéro de l'espace d'adressage associé, dans son ''tag''. Lors de chaque accès mémoire, l'ID du registre est comparé à l'ID de la ligne de cache accédée, pour vérifier que l'accès mémoire accède à la bonne donnée. Cette méthode n'est pas très économe en termes de transistors. L'usage d'identifiant de processus CPU est clairement meilleure en termes de performance, les commutations de contexte sont plus rapides. Par contre, le budget en transistor est plus important. Un autre défaut de cette méthode est que l'identifiant de processus est généralement codé sur une dizaine de bits, alors que le système d'exploitation utilise des identifiants de processus beaucoup plus larges, de 32 à 64 bits sur les CPU 32/64 bits. L'OS doit gérer la correspondance entre identifiants de processus CPU et ceux de l'OS. Parfois, pour cette raison, les OS n'utilisent pas toujours ce système d'identifiant de processus CPU. ====Les adresses synonymes perturbent aussi la gestion du cache==== La gestion des adresses synonymes est aussi un gros problème sur les caches virtuellement tagués. Pour rappel, il s'agit du cas où des adresses logiques différentes pointent vers la même adresse physique. Typiquement, quand deux programmes se partagent un morceau de mémoire, ce morceau correspondra à des adresses synonymes dans les deux espaces d'adressage. Mais il arrive que l'on ait des adresses synonymes dans le même espace d'adressage, ce n'est pas si rare ! Autant les adresses synonymes ne posent aucun problème avec les caches physiquement tagués, ce n'est pas le cas avec les caches virtuellement adressés. Sur ces caches, deux adresses logiques synonymes vont tomber dans deux lignes de cache différentes. Corriger ce problème demande d'ajouter des circuits annexes pour détecter les adresses synonymes, qui sont vraiment complexes et ont un cout en termes de performance. Aussi, les caches virtuellement tagués sont très peu utilisés sur les processeurs modernes. ===Les caches virtuellement adressés, mais physiquement tagués=== Si les caches physiquement et virtuellement tagués ont des défauts, il existe un intermédiaire qui est un bon compromis entre ces deux extrêmes. Il s'agit des '''caches virtuellement adressés - physiquement tagués''', aussi appelés '''caches pseudo-virtuels'''. Pour comprendre comment ils fonctionnent, précisons que ces caches sont soit des caches ''direct-mapped'', soit des caches associatifs par voie (composés de plusieurs RAM ''direct-mapped'' accédées en parallèle, plusieurs voies). L'accès à ce genre de cache se fait en deux temps : on accède à un ou plusieurs RAM ''direct-mapped'' et on vérifie ensuite les ''Tags'' pour sélectionner la bonne voie. Sur les caches ''direct-mapped'', on n'a qu'une seule RAM ''direct-mapped''. Sur les caches associatifs, on a plusieurs RAM ''direct-mapped'', appelées des voies, qui sont accédées en parallèle. L'accès se fait donc en deux étapes : adresser les RAM ''direct-mapped'' avec un indice, vérifier les ''tags'' avec le reste de l'adresse. Une autre chose à rappeler est que l'adresse logique est composée de deux parties : un numéro de page logique qui indique dans quel page se situe l'adresse, un décalage/''offset'' qui indique la position de l'adresse dans la page. La traduction d'adresse transforme le numéro de page logique en numéro de page physique, mais laisse le décalage intouché. L'idée est d'utiliser le décalage pour adresser les RAM avec le décalage, tandis que le numéro de page sert de ''tag''. Le décalage est découpé en deux lors de l'accès au cache : les bits de poids fort forment l'indice (l'adresse envoyée à la voie), les bits de poids faible donnent la position de l'adresse dans la ligne de cache. L'idée est d'utiliser un numéro de page physique pour les ''tags'', mais d'adresser les voies avec le décalage logique. Les deux servent à des instants différents : vérification des ''tags'' pour l'adresse physique, accès aux voies pour l'adresse logique. Ainsi, le problème des adresses synonymes ou homonymes est résolu par l'utilisation de l'adresse physique pour les tags. Par contre, l'accès au cache est plus rapide, car on utilise l'adresse logique pour la première étape. Le processeur accède à la TLB et récupère l'adresse physique pendant que l'on adresse les voies, les deux sont faits en parallèle, ce qui fait que tout se passe comme si l'accès à la TLB était gratuit. La TLB étant assez rapide comparé au cache, l'adresse physique est disponible quand on doit faire la comparaison avec les ''tags''. [[File:Virtual - Physical - Pseudo Virtual addressing.svg|centre|vignette|upright=2|Adressage pseudo virtuel des caches.]] Il s'agit d'un excellent compromis entre performance et correction des problèmes des adresses synonymes/homonymes. Tous les caches des processeurs haute performance utilisent cette méthode, au moins pour leurs caches L1. Les caches L2 tendent à utiliser des caches physiquement adressés, pour lesquels la latence d'accès est suffisante pour qu'on accède à la TLB en amont. La raison est assez simple à expliquer, elle provient d'une contrainte assez précise sur le calcul de l'indice. La conséquence est qu'un cache ''direct-mapped'' ne peut pas dépasser la taille d'une page, soit 4 kibioctets sur les ordinateurs actuels. Sur les caches associatifs, on peut dépasser cette limite en augmentant le nombre de voies, mais la taille maximale d'une voie reste celle d'une page. Cette contrainte n'est pas trop grave sur les caches de petite taille, dont les caches L1. La plupart d'entre eux ont trouvé un compromis idéal avec moins d'une dizaine de voies par cache, chacun de 4 kibioctets, ce qui donne des caches allant de 16 à 64 kibioctets, soit entre 4 et 16 voies. Par contre, un cache de grande taille doit utiliser un grand nombre de voies, ce qui est peu pratique. Aussi, cette technique de caches pseudo-virtuels n'est pas toujours appliquée sur les caches L2, qui sont physiquement adressés. Il faut dire qu'on accède au cache L2 lors d'un défaut dans le cache L1, et l'adresse physique est disponible à ce moment-là, elle a déjà été récupérée lors de l'accès au cache L1. On peut donc l'utiliser pour adresser le cache L2 sans perte de performance. ==Le remplacement des lignes de cache== Lorsqu'un cache est rempli et qu'on charge une nouvelle donnée dedans, il faut faire de la place pour cette dernière. Dans le cas d'un cache directement adressé, il n'y a rien à faire vu que la ligne de cache à évincer est déterminée lors de la conception du cache. Mais pour les autres caches, la donnée peut aller dans n'importe quelle ligne ou voie. Or, le choix des données à rapatrier en RAM doit être le plus judicieux possible : on doit virer de préférence des données inutiles. Rapatrier une donnée qui sera surement utilisée sous peu est inutile, et il vaudrait mieux supprimer des données qui ne serviront plus ou alors dans longtemps. Il existe différents algorithmes spécialement dédiés à résoudre ce problème efficacement, directement câblés dans les unités de gestion du cache. Certains sont vraiment très complexes, aussi je vais vous présenter quelques algorithmes particulièrement simples. Mais avant de voir ces algorithmes, il faut absolument que je vous parle d'une chose très importante. Quel que soit l'algorithme en question, il choisit la ligne de cache à évincer et recopie son contenu dans la RAM. Ce qui demande d'identifier et de sélectionner une ligne de cache parmi toutes les autres. Pour cela, le circuit de remplacement attribue une adresse chaque ligne de cache ! Vous avez bien vu : chaque ligne de cache est numérotée par une adresse, interne au cache. ===Le remplacement aléatoire=== Premier algorithme : la donnée effacée du cache est choisie au hasard ! C'est contre-intuitif, mais cet algorithme donne des résultats assez honorables, en plus d'utiliser très peu de portes logiques (un générateur de nombres pseudo-aléatoire est un circuit assez simple). Généralement, les défauts de cache sont séparés par un nombre assez important et irrégulier de cycles d'horloge. Dans ces conditions, cette technique donne un bon résultat. ===FIFO : first in, first out=== Avec l'algorithme FIFO, la donnée effacée du cache est la plus ancienne, celle chargée dans le cache avant les autres. Cet algorithme est très simple à implémenter en circuit, concevoir une mémoire de type FIFO n'étant pas très compliqué, comme on l’a vu dans le chapitre dédié à ce type de mémoires. Et on peut dire que dans le cas d'un cache, l'implémentation est encore plus simple et se contente d'un seul registre/compteur. Typiquement, il suffit d'ajouter un registre qui mémorise où se situe la donnée la plus récente. Toute insertion d'une nouvelle donnée se fait à l'adresse suivante, ce qui demande juste d'incrémenter le registre avant d'utiliser son contenu pour l'accès mémoire. [[File:Algorithme FIFO de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme FIFO de remplacement des lignes de cache.]] Cet algorithme possède une petite particularité sur les caches associatifs par voie : en augmentant le nombre d'ensembles, les performances peuvent se dégrader : c'est ce qu'on appelle l''''anomalie de Bélády'''. ===MRU : most recently used=== Avec l'algorithme MRU, la donnée remplacée est celle qui a été utilisée le plus récemment. Cet algorithme s'implémente simplement avec un registre, dans lequel on place le numéro de la dernière ligne de cache utilisée. Cet algorithme de remplacement est très utile quand un programme traverse des tableaux du premier élément jusqu'au dernier : les données du tableau sont rarement réutilisées, rendant le cache inutile. Il est prouvé que dans ces conditions, l'algorithme MRU est optimal. Mais dans toutes les autres conditions, cet algorithme a des performances assez misérables. ===LFU : least frequently used=== Avec l'algorithme LFU, la donnée supprimée est celle qui est utilisée le moins fréquemment. Cet algorithme s'implémente en associant un compteur à chaque ligne de cache, qui est incrémenté à chaque accès mémoire. La ligne la moins récemment utilisée est celle dont le compteur associé a la plus petite valeur. Implémenter cet algorithme prend pas mal de transistors, car il faut rajouter autant de compteurs qu'il y a de lignes de cache, en plus d'un circuit pour comparer les compteurs et d'un encodeur. [[File:Algorithme LFU de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme LFU de remplacement des lignes de cache]] ===LRU : least recently used=== Avec l'algorithme LRU, la donnée remplacée est celle qui a été utilisée le moins récemment. Cet algorithme se base sur le principe de localité temporelle, qui stipule qu'une donnée accédée récemment a de fortes chances d'être réutilisée dans un futur proche. Et inversement, la donnée la moins récemment utilisée du cache est celle qui a le plus de chance de ne servir à rien dans le futur. Autant la supprimer en priorité pour faire de la place à des données potentiellement utiles. Implémenter l'algorithme LRU peut se faire de différentes manières, qui ont pour point commun d'enregistrer les accès au cache pour en déduire la ligne la moins récemment accédée. La manière la plus simple demande d'utiliser un compteur pour chaque ligne de mémoire cache, un peu comme le LFU. La différence avec le LFU est que le compteur n'est pas incrémenté lors d'un accès mémoire. À la place, ce compteur est incrémenté régulièrement, chaque incrémentation ayant lieu en même temps pour tous les compteurs. Quand un bloc est chargé dans le cache, ce compteur est mis à zéro. Quand une ligne de cache doit être remplacée, un circuit va vérifier la valeur de tous les compteurs : la ligne LRU (la moins récemment utilisée), est celle dont le compteur a la valeur la plus haute. Le circuit est composé d'un paquet de comparateurs, et d'un encodeur, comme pour l'agorithme LFU. ===Les approximations du LRU=== Implémenter le LRU demande un nombre de transistors proportionnel au carré du nombre de lignes de cache. Autant dire que le LRU devient impraticable sur de gros caches. Ce qui fait que les processeurs modernes implémentent des variantes du LRU, moins couteuses en transistors, qui donnent un résultat approximativement semblable au LRU. En clair, ils ne sélectionnent pas toujours la ligne de cache la moins récemment utilisée, mais une ligne de cache parmi les moins récemment utilisées. Ce n'est pas un problème si grave que cela car les lignes les moins récemment utilisées ont toutes assez peu de chance d'être utilisées dans le futur. Entre choisir de remplacer une ligne qui a 0,5 % de chances d'être utilisée dans le futur et une autre qui a une chance de seulement 1 %, la différence est négligeable en termes de taux de succès. Mais les gains en termes de circuits ou de temps d'accès au cache de ces algorithmes sont très intéressants. L'algorithme le plus simple consiste à couper le cache (ou chaque voie s'il est associatif) en plusieurs sections. L'algorithme détermine la section la moins récemment utilisée, avant de choisir aléatoirement une ligne de cache dans cette section. Pour implémenter cet algorithme, il nous suffit d'un registre qui mémorise le morceau le moins récemment utilisé, et d'un circuit qui choisit aléatoirement une ligne de cache. Cette technique s'adapte particulièrement bien avec des caches associatifs à voies : il suffit d'utiliser autant de morceaux que de voies. Autre algorithme, un peu plus efficace : le '''pseudo-LRU de type M'''. Cet algorithme attribue un bit à chaque ligne de cache, bit qui sert à indiquer de façon approximative si la ligne de cache associée est une candidate pour un remplacement ou non. Il vaut 1 si la ligne n'est pas une candidate pour un remplacement et zéro sinon. Le bit est mis à 1 lorsque la ligne de cache associée est lue ou écrite. Évidemment, au fil du temps, toutes les lignes du cache finiront par avoir leur bit à 1. Lorsque cela arrive, l'algorithme remet tous les bits à zéro, sauf pour la dernière ligne de cache accédée. L'idée derrière cet algorithme est d'encercler la ligne de cache la moins récemment utilisée au fur et à mesure des accès. L'encerclement commence lorsque l'on remet tous les bits associés aux lignes de cache à 0, sauf pour la ligne accédée en dernier. Au fur et à mesure des accès, l'étau se resserre autour de la ligne de cache la moins récemment utilisée. Après un nombre suffisant d'accès, l'algorithme donne une estimation particulièrement fiable. Et comme les remplacements de lignes de cache sont rares comparés aux accès aux lignes, cet algorithme finit par donner une bonne estimation avant qu'on ait besoin d'effectuer un remplacement. Le dernier algorithme d'approximation, le '''PLURt''', se base sur ce qu'on appelle un arbre de décision. Il a besoin de n − 1 bits pour déterminer la ligne LRU. Ces bits doivent être organisés en arbre, comme illustré plus bas. Chacun de ces bits sert à dire : le LRU est à ma droite ou à ma gauche : il est à gauche si je vaux 0, et à droite si je vaux 1. Trouver le LRU se fait en traversant cet arbre, et en interprétant les bits un par un. Au fur et à mesure des lectures, les bits sont mis à jour dans cet arbre, et pointent plus ou moins bien sur le LRU. La mise à jour des bits s'effectue lors des lectures et écritures : quand une ligne est lue ou écrite, elle n'est pas la ligne LRU. Pour l'indiquer, les bits à 1 qui pointent vers la ligne de cache sont mis à 0 lors de la lecture ou écriture. {| |[[File:Organisation des bits avec l'algorithme PLURt.jpg|vignette|Organisation des bits avec l'algorithme PLURt.]] |[[File:Ligne de cache pointée par les bits de l'algorithme.png|vignette|Ligne de cache pointée par les bits de l'algorithme.]] |} ===LRU amélioré=== L'algorithme LRU, ainsi que ses variantes approximatives, sont très efficaces tant que le programme respecte relativement bien la localité temporelle. Par contre, Le LRU se comporte assez mal dans les circonstances ou la localité temporelle est mauvaise mais où la localité spatiale est respectée, le cas le plus emblématique étant le parcours d'un tableau. Pour résoudre ce problème, des variantes du LRU existent. Une variante très connue, l''''algorithme 2Q''', utilise deux caches : un cache FIFO pour les données accédées une seule fois et un second cache LRU. Évidemment, les données lues une seconde fois sont migrées du cache FIFO vers le cache LRU, ce qui n'est pas très pratique. Les processeurs n'utilisent donc pas cette technique, mais celle-ci est utilisée dans les caches de disque dur. D'autres variantes du LRU combinent plusieurs algorithmes à la fois et vont choisir lequel de ces algorithmes est le plus adapté à la situation. Notre cache pourra ainsi détecter s’il vaut mieux utiliser du MRU, du LRU, ou du LFU suivant la situation. ==Les écritures dans le cache : gestion et optimisations== Les écritures se font à une adresse mémoire bien précise, qui peut ou non être chargée dans le cache. Si la donnée à écrire est chargée dans le cache, elle est modifiée directement dans le cache, mais elle ne l'est pas forcément en mémoire RAM. Suivant le processeur, les écritures sont ou non propagées en mémoire RAM. Il existe deux stratégies d'écritures, appelées respectivement le ''write-back'' et le ''write-through''. Avec un cache ''write-back'', si la donnée à mettre à jour est présente dans le cache, on écrit dans celui-ci sans écrire dans la mémoire RAM. Dans ces conditions, une donnée n'est enregistrée en mémoire que si celle-ci quitte le cache, ce qui évite de nombreuses écritures mémoires inutiles. [[File:Cache write-through.png|centre|vignette|upright=2|Cache write-through.]] Avec les caches '''Write-Through''', toute écriture dans le cache est propagée en RAM. Cette stratégie augmente le nombre d'écritures dans la mémoire RAM, ce qui peut saturer le bus reliant le processeur à la mémoire. Les performances de ces caches sont donc légèrement moins bonnes que pour les caches ''write back''. Par contre, ils sont utiles dans les architectures avec plusieurs processeurs, comme nous le verrons dans les chapitres sur les architectures multiprocesseurs. [[File:Cache write-back.png|centre|vignette|upright=2|Cache write-back.]] ===Les caches ''Write-through''=== Sans optimisation particulière, on ne peut écrire dans un cache ''write-through'' pendant qu'une écriture en RAM a lieu en même temps : cela forcerait à effectuer deux écritures simultanées, en comptant celle imposée par l'écriture dans le cache. Pour éviter cela, certains caches ''write-through'' intègrent un '''tampon d’écriture''', qui sert de file d'attente pour les écritures en RAM. C'est une mémoire FIFO dans laquelle on place temporairement les données à écrire en RAM, où elles attendent en attendant que la RAM soit libre. Grâce à lui, le processeur peut écrire dans un cache même si d'autres écritures sont en attente dans le tampon d'écriture. Par souci d'efficacité, des écritures à la même adresse en attente dans le tampon d’écriture sont fusionnées en une seule. Cela fait un peu de place dans le tampon d’écriture, et lui permet d'accumuler plus d'écritures avant de devoir bloquer le cache. Il est aussi possible de fusionner des écritures à adresses consécutives de la mémoire en une seule écriture en rafales. Dans les deux cas, on parle de '''combinaison d'écriture'''. Mais la technique du tampon d'écriture a cependant un léger défaut qui se manifeste dans une situation bien précise : quand le processeur veut lire une donnée en attente dans le tampon d’écriture. La première manière de gérer cette situation est de mettre en attente la lecture tant que la donnée n'a pas été écrite en mémoire RAM. On peut aussi lire la donnée directement dans le tampon d'écriture, cette optimisation portant le nom de '''''store-to-load forwading'''''. Dans tous les cas, il faut détecter le cas où une lecture accède à une donnée dans le tampon d'écriture. À chaque lecture, l'adresse à lire est envoyée au tampon d'écriture, qui vérifie si une écriture en attente se fait à cette adresse. Pour cela, le tampon d’écriture doit être un cache, dont chaque entrée mémorise une écriture. Chaque ligne de cache contient la donnée à écrire, et le tag de la ligne de cache contient l'adresse où écrire la donnée. Notons que cache d'écriture a une politique de remplacement de type FIFO, le tampon d'écriture non-optimisé étant une mémoire FIFO. ===Les caches ''Write-back''=== Les caches ''write-back'' ont beau avoir des performances supérieures à celles des caches ''write-through'', il existe des optimisations qui permettent d'améliorer leurs performances. Ces optimisations consistent à ajouter des caches spécialisés à côté du cache proprement dit. Ces caches permettent de mémoriser des données qui sont éliminées du cache par les algorithmes de remplacement de ligne cache, sans pour autant faire une écriture en RAM. En suivant la procédure habituelle de remplacement des lignes de cache, on doit rapatrier la ligne en RAM avant d'en charger une nouvelle. On peut améliorer la situation en faisant l'inverse : on charge la nouvelle ligne pendant que l'ancienne donnée est rapatriée en RAM. Ainsi, la nouvelle ligne est disponible plus tôt pour le processeur, diminuant son temps d'attente. Pour implémenter cette technique, on doit mémoriser l'ancienne ligne de cache temporairement dans un '''cache d’éviction''' (ou ''write-back buffer''). [[File:Cache d’éviction.png|centre|vignette|upright=2|Cache d’éviction]] Les caches directement adressés ou associatifs par voie possèdent aussi un tampon d’écriture amélioré. Pour limiter les défauts par conflit de ces caches, des scientifiques ont eu l'idée d'insérer un cache pour stocker les données virées du cache. En faisant ainsi, si une donnée est virée du cache, on peut alors la retrouver dans ce cache spécialisé. Ce cache s'appelle le '''cache de victime'''. Ce cache de victime est géré par un algorithme de suppression des lignes de cache de type FIFO. Petit détail : ce cache utilise un tag légèrement plus long que celui du cache directement adressé au-dessus de lui. L'index de la ligne de cache doit en effet être contenu dans le tag du cache de victime, pour bien distinguer deux adresses différentes, qui iraient dans la même ligne du cache juste au-dessus. [[File:Victim Cache Implementation Example.svg|centre|vignette|upright=1|Cache de victime.]] ===La configuration du fonctionnement du cache=== Sur de nombreux processeurs, il est possible de configurer la mémoire cache pour qu'elle fonctionne soit en mode ''write-back'', soit en mode ''write-through''. Pour cela, les processeurs modernes incorporent des '''registres de configuration du cache'''. Le terme ''registre de configuration du cache'' est assez transparent et indique bien quel est leur rôle. Ils configurent comment le cache est utilisé et permettent notamment de configurer le cache pour dire s'il doit fonctionner en mode ''write-back'' ou ''write-through''. Ils permettent aussi d'activer ou de désactiver la combinaison sur écriture. Les registres en question sont configurés soit par le BIOS, soit par le système d'exploitation. Ce sont des registres protégés, que les applications ne peuvent pas configurer, elles n'en ont pas le droit. Typiquement, ils ne sont accessibles en écriture qu'en mode noyau. Sur les processeurs x86, les registres de configuration du cache sont appelés des '''''Memory type range registers''''' (''MTRRs''). Les MTRRs sont assez nombreux, et il y a notamment une différence entre mode réel et protégé. Si vous vous souvenez des chapitres sur le mode d'adressage et la mémoire virtuelle, vous vous souvenez que les processeurs x86 incorporent plusieurs modes de fonctionnement. En mode réel, le processeur ne peut adresser qu'un mébioctet de RAM, avec un système de segmentation particulier. En mode protégé, le processeur peut adresser toute la mémoire et la segmentation fonctionne différemment, quand elle n'est pas simplement désactivée. Les MTRRs sont séparés en deux : ceux pour le mode réel, ceux pour le mode protégé. Les MTRRs fixes sont ceux qui configurent le cache en mode réel, ils étaient utilisés pour gérer l'accès au BIOS, à la mémoire VGA de la carte graphique, et quelques autres accès aux entrées-sorties basiques gérées nativement par le BIOS. Pour le mode protégé, les processeurs au-delà du 386 incorporent des MTRRs variables, qui servent pour les autres entrées-sorties en général, notamment les périphériques PCI, la mémoire vidéo de la carte graphique, et j'en passe. De nos jours, les registres de configuration du cache sont désuets et cette fonctionnalité est gérée directement par la mémoire virtuelle. La table des pages contient, pour chaque page mémoire, des bits de contrôle qui disent si la page mémoire est cacheable ou non. Le contournement de cache est alors géré par le système de mémoire virtuelle, le cache de TLB et tout ce qui va avec. ===L’allocation sur écriture=== Que faire quand une écriture modifie une donnée qui n'est pas dans le cache ? Doit-on écrire la donnée dans le cache, ou non ? Si la donnée est écrite dans le cache, on dit que le cache fait une '''allocation sur l'écriture''' (ou ''write-allocate''). Certains caches effectuent une telle allocation sur écriture, mais d'autres ne le font pas ou du moins pas systématiquement. L’allocation sur écriture peut se décliner en deux sous-catégories : le '''chargement à la demande''' et l''''écriture immédiate'''. Dans le premier cas, on charge la donnée à modifier dans le cache, et on la remplace avec la donnée écrite. Dans l'écriture immédiate, l'écriture a lieu directement dans le cache et la donnée à modifier n'est pas chargée dans le cache. Évidemment, seule une portion de la ligne de cache contient la donnée écrite (valide), et le reste contient des données invalides. Le cache doit savoir quelles sont les portions du cache qui sont valides : cela demande d'utiliser un ''sector cache''. [[File:Write-back with write-allocation.svg|centre|vignette|upright=2|Cache Write-back avec allocation sur écriture.]] Sans allocation sur écriture, l'écriture est transférée directement aux niveaux de cache inférieurs ou à la mémoire si la donnée à modifier n'est pas dans le cache. Certains caches de ce genre utilisent une petite optimisation : lors de toute écriture, ils supposent que l'écriture donnera un succès de cache. Si c'est le cas, la ligne de cache qui contient la donnée est mise à jour avec la donnée à écrire. Mais si ce n'est pas le cas, la ligne de cache est invalidée, et l'écriture est transférée directement à la mémoire ou aux niveaux de cache inférieurs. [[File:Write-through with no-write-allocation.svg|centre|vignette|upright=2|Cache Write-through sans allocation sur écriture.]] ===La cohérence des caches=== Il arrive parfois que la mémoire d'un ordinateur soit mise à jour, sans que les modifications soient répercutées dans les mémoires cache. Dans ce cas, le cache contient une donnée périmée. Or, un processeur doit toujours éviter de se retrouver avec une donnée périmée et doit toujours avoir la valeur correcte dans ses caches : cela s'appelle la '''cohérence des caches'''. Il est possible de se retrouver avec des valeurs périmées dans le cache sur les ordinateurs avec plusieurs processeurs, ou si un périphérique écrit en RAM, les modifications ne sont pas répercutées automatiquement dans les mémoires cache. Pour résoudre ce problème, on peut interdire de charger dans le cache des données stockées dans les zones de la mémoire dédiées aux périphériques. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires cache. Autre solution : utiliser le fait que les périphériques déclenchent une interruption matérielle pour laisser le contrôleur DMA accéder à la mémoire. Dans ce cas, il suffit de vider les caches à chaque interruption matérielle. Le processeur peut le faire automatiquement, ou fournir des instructions pour. ==Le ''cache bypassing'' : contourner le cache== Dans certaines situations, le cache n'est pas utilisé pour certains accès mémoire. Diverses techniques permettent en effet d'effectuer des accès mémoire qui contournent le cache, qui ne passent pas par le cache. Ils sont utilisés quand l'accès en cache fait que des instructions normales ne fonctionnent pas. Par exemple, de tels accès directs à la RAM sont notamment utilisés pour l'implémentation d'instructions atomiques, une classe d'instructions spécifiques utilisées sur les processeurs multicœurs, dont nous parlerons dans plusieurs chapitres. Mais ils sont aussi utilisés pour l'accès aux périphériques, ce que nous allons voir maintenant. ===Accéder aux périphériques demande de contourner le cache=== Pour rappel, un périphérique (au sens d'entrée-sortie) contient des registres d’interfaçage qui ont une adresse au même titre que les cases mémoire. Un périphérique peut à tout instant modifier ses registres d’interfaçage, ce qui se répercute automatiquement dans l'espace d'adressage, mais rien de tout cela n'est transmis au cache. Si les accès aux périphériques passaient par l'intermédiaire du cache, on aurait droit à des problèmes. On aurait encore une fois droit à des problèmes de cohérence des caches. Le problème est géré différemment suivant que l'on utilise un espace d'adressage séparé ou des entrées-sorties mappées en mémoire. La solution est que les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache. Cela demande d'adapter le cache et le processeur. L'implémentation exacte dépend de comment sont adressés les périphériques. Pour rappel, il y a deux solutions pour adresser les périphériques : soit les périphériques disposent d'un espace d'adressage séparé de celui de la mémoire, soit il y un espace d'adressage unique partagé entre processeur et mémoire. Les deux cas donnent des solutions différentes. Avec un espace d'adressage séparé, l'espace d'adressage des périphériques n'est pas caché : aucun accès dans cet espace d'adressage ne passe par le cache. La mémoire cache n'est utilisée que pour l'espace d'adressage des mémoires, rien d'autre. C'est de loin le cas le plus simple : il suffit de concevoir le processeur pour. Il dispose d'instructions séparées pour les accès aux registres d’interfaçage et à la RAM/ROM, les premières ne passent pas par le cache, les autres si. Avec des entrées-sorties mappées en mémoire, la même solution est utilisée, mais dans une version un peu différente. Là encore, les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache, si on veut qu'ils marchent comme ils le doivent. Cela demande d'adapter le cache et le matériel pour que accès aux périphériques mappés en mémoire contournent le cache. Des adresses, voire des zones entières de la mémoire, sont marquées comme étant non-cachables. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires caches. Là encore, le processeur doit être prévu pour : on doit pouvoir le configurer de manière à marquer certaines zones de la RAM comme non-cacheable. Reste qu'il faut marquer des régions de la RAM comme non-cacheable. Pour cela, on améliore les registres de configuration du cache, vus plus haut, afin qu'ils permettent de configurer certaines portions de la RAM pour préciser qu'elles ne doivent pas être mises en cache, qu'il faut activer le contournement de cache pour celles-ci. ===Contourner le cache pour des raisons de performance=== Il arrive que des données avec une faible localité soient chargées dans le cache inutilement. Or, il vaut mieux que ces données transitent directement entre le processeur et la mémoire, sans passer par l'intermédiaire du cache. Pour cela, le processeur peut fournir des instructions d'accès mémoire qui ne passent pas par le cache, à côté d'instructions normales. De telle instructions sont appelées des '''instructions mémoire non-temporelles'''. Non-temporelle, dans le sens : pas de localité temporelle (c.a.d que les données ne seront pas réutilisées plus tard). Mais il existe aussi des techniques matérielles, où le cache détecte à l'exécution les lectures qui gagnent à contourner le cache. La dernière méthode demande d'identifier les instructions à l'origine des défauts de cache, le processeur accédant directement à la RAM quand une telle instruction est détectée. Si une instruction d'accès mémoire fait trop de défauts de cache, c'est signe qu'elle gagne à contourner le cache. L'idée est de mémoriser, pour chaque instruction d'accès mémoire, un historique de ses défauts de cache. Il existe plusieurs méthodes pour cela, mais toutes demandent d'ajouter de quoi mémoriser l'historique des défauts de cache des instructions. L'historique est mémorisé dans une mémoire appelée la '''table d’historique des défauts de lecture''' (''load miss history table''), qui est souvent un cache. L'historique en question est, dans sa version la plus simple, un compteur de quelques bits incrémenté à chaque succès de cache et décrémenté à chaque défaut de cache, qui indique si l'instruction a en moyenne fait plus de défauts ou de succès de cache. La table associe le ''program counter'' d'une instruction mémoire à cet historique. À la première exécution d'une instruction d'accès mémoire, une entrée de cette table est réservée pour l'instruction. Lors des accès ultérieurs, le processeur récupérer les informations associées et décide s'il faut contourner le cache ou non. ==La hiérarchie mémoire des caches== [[File:Cache Hierarchy.png|vignette|Hiérarchie de caches]] On pourrait croire qu'un seul cache est largement suffisant pour compenser la lenteur de la mémoire. Hélas, les processeurs sont devenus tellement rapides que les caches sont eux-mêmes très lents ! Pour rappel, plus une mémoire peut contenir de données, plus elle est lente. Et les caches ne sont pas épargnés. Si on devait utiliser un seul cache, celui-ci serait très gros et donc trop lent. La situation qu'on cherche à éviter avec la mémoire RAM revient de plus belle. Même problème, même solution : si on a décidé de diviser la mémoire principale en plusieurs mémoires de taille et de vitesse différentes, on peut bien faire la même chose avec la mémoire cache. Depuis environ une vingtaine d'années, un processeur contient plusieurs caches de capacités très différentes : les caches L1, L2 et parfois un cache L3. Certains de ces caches sont petits, mais très rapides : c'est ceux auxquels on va accéder en priorité. Viennent ensuite d'autres caches, de taille variable, mais plus lents. Les processeurs ont donc une hiérarchie de caches qui se fait de plus en plus complexe avec le temps. Cette hiérarchie est composée de plusieurs niveaux de cache, qui vont des niveaux inférieurs proches de la mémoire RAM à des niveaux supérieurs proches du processeur. Plus on monte vers les niveaux supérieurs, plus les caches sont petits et rapides. Un accès mémoire dans une hiérarchie de cache fonctionne comme suit : on commence par vérifier si la donnée recherchée est dans le cache le plus rapide, à savoir le cache L1. Si c'est le cas,n on la charge depuis ce cache directement. Si elle n’y est pas, on vérifie si elle est dans le cache de niveau supérieur, le cache L2. Et rebelote ! Si elle n'y est pas, on vérifie le cache du niveau supérieur. Et on répète cette opération, jusqu’à avoir vérifié tous les caches. Si la donnée n'est dans aucun cache, on doit alors aller chercher la donnée en mémoire. [[File:Hiérarchie de caches.png|centre|vignette|upright=2|Hiérarchie de caches]] Il y a des différences assez notables entre chaque niveau de cache. Par exemple, les différents niveaux de cache n'ont pas forcément les mêmes politiques de remplacement des lignes de cache. Le cache L1 a généralement une politique de remplacement simple, très rapide, mais peu efficace. De même, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Le cache le plus proche de la mémoire est appelé le '''cache de dernier niveau''', ''Last Level Cache'' en anglais. Il a parfois des caractéristiques totalement différentes des autres caches. Par exemple, sur les processeurs multicoeurs, le cache L3 n'est techniquement pas dans le cœur, mais fait partie d'un ensemble de circuits reliés, comme le contrôleur mémoire ou l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc. ===Les caches exclusifs et inclusifs=== Notons que du point de vue de cette vérification, il faut distinguer les caches inclusifs et exclusifs. Avec les caches inclusifs, si une donnée est présente dans un cache, alors elle est présente dans les caches des niveaux inférieurs, ce qui implique l'existence de données en doublon dans plusieurs niveaux de cache. À l'opposé, les caches exclusifs font que toute donnée est présente dans un seul cache, pas les autres. Il existe aussi des caches qui ne sont ni inclusifs, ni exclusifs. Sur ces caches, chaque niveau de cache gère lui-même ses données, sans se préoccuper du contenu des autres caches. Pas besoin de mettre à jour les niveaux de cache antérieurs en cas de mise à jour de son contenu, ou en cas d'éviction d'une ligne de cache. La conception de tels caches est bien plus simple. Dans les '''caches exclusifs''', le contenu d'un cache n'est pas recopié dans le cache de niveau inférieur. Il n'y a pas de donnée en double et on utilise 100 % de la capacité du cache, ce qui améliore le taux de succès. Par contre, le temps d'accès est un peu plus long. La raison est que si une donnée n'est pas dans le cache L1, on doit vérifier l'intégralité du cache L2, puis du cache L3. De plus, assurer qu'une donnée n'est présente que dans un seul cache nécessite aux différents niveaux de caches de communiquer entre eux pour garantir que l'on a pas de copies en trop d'une ligne de cache, ce qui peut prendre du temps. [[File:Caches exclusifs.png|centre|vignette|upright=2|Caches exclusifs]] Dans le cas des '''caches inclusifs''', le contenu d'un cache est recopié dans les caches de niveau inférieur. Par exemple, le cache L1 est recopié dans le cache L2 et éventuellement dans le cache L3. Ce genre de cache a un avantage : le temps d'accès à une donnée est plus faible. La raison est qu'il ne faut pas vérifier tout un cache, mais seulement la partie qui ne contient pas de donnée en doublon. Par exemple, si la donnée voulue n'est pas dans le cache L1, on n'est pas obligé de vérifier la partie du cache L2 qui contient la copie du L1. Ainsi, pas besoin de vérifier certaines portions du cache, ce qui est plus rapide et permet de simplifier les circuits de vérification. En contrepartie, l'inclusion fait que qu'une partie du cache contient des copies inutiles, comme si le cache était plus petit. De plus, maintenir l'inclusion est compliqué et demande des circuits en plus et/ou des échanges de données entre caches. [[File:Caches inclusifs.png|centre|vignette|upright=2|Caches inclusifs]] Maintenir l'inclusion demande de respecter des contraintes assez fortes, ce qui ne se fait pas facilement. Premièrement, toute donnée chargée dans un cache doit aussi l'être dans les caches de niveau inférieur. Ensuite, quand une donnée est présente dans un cache, elle doit être maintenue dans les niveaux de cache inférieurs. De plus, toute donnée effacée d'un cache doit être effacée des niveaux de cache supérieurs : si une donnée quitte le cache L2, elle doit être effacée du L1. Ces trois contraintes posent des problèmes si chaque cache décide du remplacement des lignes de cache en utilisant un algorithme comme LRU, LFU, MRU, ou autre, qui utilise l'historique des accès. En effet, dans ce cas, le cache décide de remplacer les lignes de cache selon l'historique des accès, historique qui varie suivant chaque niveau de cache. Par exemple, une donnée rarement utilisée dans le L2 peut parfaitement être très fréquemment utilisée dans le L1 : la donnée sera alors remplacée dans le L2, mais sera maintenue dans le L1. On observe aussi des problèmes quand il existe plusieurs caches à un seul niveau : chaque cache peut remplacer les lignes de cache d'une manière indépendante des autres caches du même niveau, donnant lieu au même type de problème. Pour maintenir l'inclusion, les caches doivent se transmettre des informations qui permettent de maintenir l'inclusion. Par exemple, les caches de niveaux inférieurs doivent prévenir les niveaux de cache supérieurs quand ils remplacent une ligne de cache. De plus, toute mise à jour dans un cache doit être répercutée dans les niveaux de cache inférieurs et/ou supérieurs. On doit donc transférer des informations de mise à jour entre les différents niveaux de cache. Généralement, le contenu des caches d'instruction n'est pas inclus dans les caches de niveau inférieurs, afin d'éviter que les instructions et les données se marchent sur les pieds. Enfin, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Dans ce cas, l'inclusion est plus difficile à maintenir, pour des raisons assez techniques. ===Les caches eDRAM, sur la carte mère et autres=== D'ordinaire, les mémoires caches sont intégrées au processeur, à savoir que cache et CPU sont dans le même circuit imprimé. Les caches sont donc fabriqués avec de la SRAM, seule forme de mémoire qu'on peut implémenter dans un circuit intégré. Intégrer tous les caches dans le processeur est une solution et efficace. Mais certains processeurs ont procédé autrement. [[File:Cache-on-a-stick module.jpg|vignette|Cache-on-a-stick module]] Des processeurs assez anciens incorporaient un cache L1 dans le processeur, mais plaçaient un cache L2 sur la carte mère. Le cache était clippé sur un connecteur sur la carte mère, un peu comme le sont les barrettes de mémoire. On parlait alors de '''''Cache on a stick''''' (COAST). On aurait pu s'attendre à ce que de tels caches soient en DRAM, vu qu'ils sont placés sur des barrettes de RAM, mais la ressemblance avec la mémoire RAM principale s'arrête là. Le cache était fabriqué en mémoire SRAM, même s'il est en théorie possible de faire de tels caches avec de la DRAM. Les premiers processeurs avec un cache faisaient ainsi, au début des années 90. Il a été introduiot sur les processeurs Motorola, et a été utilisé sur les IBM PC et les Macintosh de l'époque. Les ordinateurs Macintosh utilisaient de tels caches, pour la pluaprt des modèles. Pour ce qui est des PC, les premiers processeurs x86 faisaient pareil, notamment les processeurs Intel. Le 486, le Pentium et le Pentium 2 utilisaient des ''Cache on a stick''. L'avantage est que cela permettait de mettre plus de cache, à une époque où les circuits étaient limités en transistors. De plus, cela permettait au consommateur de choisir quelle quantité de cache il voulait, selon ses finances. Il était possible de laisser le processeur fonctionner soit sans mémoire cache, soit avec un cache de 256 Kibioctets, de 512 Kibioctets, etc. Il était possible d'upgrader le cache si besoin. Pour les CPU Intel, le cache était connecté sur le bus système, au même titre que la mémoire RAM et les entrées-sorties. Il faut dire que les processeurs de l'époque utilisaient un bus système et n'avaient pas de bus mémoire dédié. Mais en théorie, rien n’empêche de connecter le cache sur un bus mémoire dédié. Toujours est-il que les lectures et écritures étaient propagées à la fois dans le cache et la RAM. Les écritures se faisaient dans les deux, systématiquement dans la RAM, mais aussi dans le cache en cas de succès de cache. Les lectures étaient servies soit par le cache en cas de succès de cache, soit par la RAM en cas de défaut de cache. Si le cache répondait en premier, la transaction sur le bus se terminait précocement et l'accès en RAM était abandonné. [[File:Intel486 Иерархия памяти.png|centre|vignette|upright=2.5|Intel486 : le cache était connecté sur le bus système.]] À l'inverse, certains processeurs possédaient un cache fabriqué en mémoire DRAM, et plus précisément avec de la mémoire eDRAM. Le cache n'était pas intégré dans le même circuit imprimé que le processeur, mais profitait d'une architecture en ''chiplet''. Pour rappel, cela veut dire que le processeur est en réalité composé de plusieurs circuits intégré séparés, mais interconnectés et soudés sur un même PCB carré. Avec un cache en eDRAM, le cache avait son propre circuit intégré, séparé du circuit intégré du processeur ou du circuit intégré pour le contrôleur mémoire/IO. Un exemple est celui du cache des processeurs Intel de microarchitecture Broadwell, vus dans ce chapitre dans la section sur les caches splittés. Les tags étaient intégrés dans le circuit intégré du processeur, mais les données étaient mémorisées dans une puce d'eDRAM séparée. La puce eDRAM correspondait en réalité à une DRAM adressable qui servait de DRAM pour les données et mémorisaient les voies du cache. ==Les caches adressés par somme et hashés== Les caches adressés par somme sont optimisés pour incorporer certains calculs d'adresse directement dans le cache lui-même. Pour rappel, certains modes d'adressage impliquent un calcul d'adresse, qui ajoute une constante à une adresse de base. Généralement, l'adresse de base est l'adresse d'un tableau ou d'une structure, et la constante ajoutée indique la position de la donnée dans le tableau/la structure. Les caches hashés et les caches adressés par somme permettent de faire l'addition directement dans la mémoire cache. Voyons d'abord les caches hashés, avant de passer aux caches adressés par somme. Sur les '''caches hashés''', l'addition est remplacée par une autre opération, par exemple des opérations bit à bit du style XOR, AND ou OR, etc. Seulement, utiliser des opérations bit à bit pose un problème : il arrive que deux couples Adresse/décalage donnent le même résultat. Par exemple, le couple Adresse/décalage 11101111/0001 donnera la même adresse que le couple 11110000/0000. Dit autrement, deux adresses censées être différentes (après application du décalage) sont en réalité attribuées à la même ligne de cache. Il est toutefois possible de gérer ces situations, mais cela demande des astuces de haute volée pour faire fonctionner la mémoire cache correctement. Sur les '''caches adressés par somme''', le décodeur est modifié pour se passer de l'addition. Pour comprendre comment, il faut rappeler qu'un décodeur normal est composé de comparateurs, qui vérifient si l'entrée est égale à une constante bien précise. Sur un cache ordinaire, l'addition est faite séparément du décodage des adresses par le cache, dans l'unité de calcul ou dans l'unité de génération d'adresse. [[File:Non sum adressed cache.png|centre|vignette|upright=2|Cache normal.]] Mais les caches adressés par somme modifient le décodeur, qui est alors composé de comparateurs qui testent si la somme adresse + décalage est égale à une constante. [[File:Cache adressé par somme.png|centre|vignette|upright=2|Cache adressé par somme.]] Chaque circuit du décodeur fait le test suivant, avec K une constante qui dépend du circuit : : <math>A + B = K</math> Ce qui est équivalent à faire le test suivant : : <math>A + B - K = 0</math> En complément à deux, on a <math>- K = \overline{K} + 1</math>. En injectant dans l'équation précédente, on a : : <math>A + B + \overline{K} + 1 = 0</math> En réorganisant les termes, on a : : <math>A + B + \overline{K} = - 1</math> Il suffit d'utiliser un additionneur ''carry-save'' pour faire l'addition des trois termes. Rappelons qu'un tel additionneur fournit deux résultats en sortie : une somme calculée sans propager les retenues et les retenues en question. Notons que les retenues sont à décaler d'un cran, vu qu'elles sont censées s'appliquer à la colonne suivante. En notant la somme S et les retenues R, on a: : <math>S + (R << 1) = - 1 </math>, le décalage d'un cran à gauche étant noté <math><< 1</math>. Ensuite, -1 est codé avec un nombre dont tous les bits sont à 1 en complément à un/deux. : <math>S + (R << 1) = 111 \cdots 111111</math> [[File:Sum + retenue add.png|centre|vignette|upright=2|Sum + retenue add]] Un simple raisonnement nous permet de savoir si le résultat est bien -1, sans faire l'addition <math>S + (R << 1)</math>. En effet, on ne peut obtenir -1 que si la somme est l'inverse des retenues : un 0 dans le premier nombre correspond à un 1 dans l'autre, et réciproquement. En clair, on doit avoir <math>\overline{S} = R << 1</math>. Pour vérifier cela, il suffit de faire un simple XOR entre la somme et les retenues décalées d'un cran. On a alors : : <math>S \oplus (R << 1) = 111 \cdots 111111</math> La comparaison avec -1 se fait avec une porte ET à plusieurs entrées. En effet, la porte donnera un 1 seulement si tous les bits d'entrée sont à 1, ce qui est ce qu'on veut tester. Au final, l'additionneur pour l'addition adresse + décalage est remplacé par un additionneur carry-save suivi d'une couche de portes XOR et d'un comparateur avec une constante, ce qui économise de circuits et améliore les performances. [[File:Final circuit of sum addressed cache.png|centre|vignette|upright=2|Cache adressé par somme.]] En prenant en compte que la constante K est justement une constante, certaines entrées de l'additionneur carry-save sont toujours à 0 ou à 1, ce qui permet quelques simplifications à grand coup d’algèbre de Boole. Chaque additionneur complet qui compose l’additionneur carry-save est remplacée par des demi-additionneurs (ou par un circuit similaire). Autant dire que l'on gagne tout de même un petit peu en rapidité, en supprimant une couche de portes logiques. Le circuit de décodage économise aussi des portes logiques, ce qui est appréciable. ==Les caches à accès uniforme et non-uniforme== Intuitivement, le temps d'accès au cache est le même pour toutes les lignes de cache. Il s'agit de cache appelés '''caches à accès uniforme''', sous-entendu à temps d'accès uniforme. Mais sur les caches de grande capacité, il arrive souvent que le temps de propagation des signaux varie fortement suivant la ligne de cache à lire. D'ordinaire, on se cale sur la ligne de cache la plus lente pour caler la fréquence d'horloge du cache, même si on pourrait faire mieux. Cependant, les '''caches à accès non uniforme''' ont une latence différente pour chaque ligne d'un même cache. Certaines lignes de cache sont plus rapides que d'autres. Niveau terminologie, nous allons parler de caches UCA et NUCA : ''Uniform Access Cache'' pour les caches à accès uniforme, ''Non-Uniform Access Cache'' pour les caches à accès non-uniforme. [[File:Caches UCA et NUCA.png|vignette|Caches UCA et NUCA.]] Les caches NUCA et UCA sont souvent composés de plusieurs banques séparées, typiquement une par voie. Sur les caches UCA, les banques sont interconnectées avec le processeur de manière à ce que toutes les interconnexions ont la même longueur pour toutes les banques. Typiquement, les banques sont organisées en carré, avec les interconnexions qui partent du centre, avec une disposition en H, illustrée ci-contre Mais avec les caches NUCA, ce n'est pas le cas. Les interconnexions sont simplifiées et ont des longueurs différentes. Les caches NUCA n'ont pas tous le même genre d'interconnexions, qui dépendent du cache NUCA. En général, les interconnexion forme un réseau avec des sortes de routeurs qui redirigent les données/commandes vers la bonne destination : cache ou processeur. Les banques plus proches du processeur sont accessibles plus rapidement que celles éloignées, même si la différence n'est pas énorme. Les caches NUCA sont généralement associatifs par voie. Les plus simples utilisent une banque par voie pour le cache, ce qui fait que certaines voies répondent plus vite que les autres. La détection des succès de cache est alors plus rapide si la donnée lue/écrite est dans une voie/banque rapide. En théorie, les défauts de cache demandent de vérifier toutes les banques, et se calent donc sur la pire latence. Mais divers caches se débrouillent pour que ce ne soit pas le cas, soit en vérifiant les banquyes unes par une, soit par un mécanisme de recherche plus complexe. Les caches NUCA sont surtout utilisés pour les caches L3 et L4, éventuellement les caches L2. Les caches L1 sont systématiquement des caches UCA, car la latence de l'accès au cache L1 est utilisée par le processeur pour décider quand lancer les instructions. Pour simplifier, le processeur peut démarrer en avance une instruction avant qu'une opérande soit lue dans le cache L1, de manière à ce que la donnée arrive en entrée de l'ALU pile en même temps que l'instruction. Une histoire d'exécution dans le désordre et d'émission anticipée des instructions qu'on détaillera dans une bonne dizaine de chapitres. Toujours est-il que tout est plus simple pour le processeur si le cache L1 a un temps d'accès fixe. Par contre, les caches L3 et L4 sont traités en attendant que les données arrivent, le processeur reprend l'exécution des instructions quand les caches L3 et L4 ont terminé de répondre, pas avant. Avec l'association une banque = une voie, la correspondance ligne de cache → bloc de mémoire qui est statique : on ne peut pas déplacer le contenu d'une ligne de cache dans une autre portion de mémoire plus rapide suivant les besoins. Mais la recherche académique a étudié le cas où la correspondance entre une ligne de cache et une banque varie à l’exécution. Pour nommer cette distinction, on parle de caches S-NUCA (''Static NUCA'') et D-NUCA (''Dynamic NUCA''). Intuitivement, on s'attend à ce que les caches D-NUCA soient plus performants que les caches S-NUCA. Les lignes de cache les plus utilisées peuvent migrer dans une banque rapide, alors que les lignes de cache moins utilisées vont dans une banque éloignée. Les lignes de cache se répartissent dans le cache dynamiquement dans les banques où elles sont le plus adaptées. Mais paradoxalement, le gain des caches D-NUCA est presque nul, voire insignifiant. La raison est que les caches D-NUCA doivent incorporer un système pour déterminer dans quelle banque se situe la donnée pour détecter les succès/défauts de cache, ainsi qu'un système pour migrer les données entre banques. Et ce système augmente le temps d'accès au cache, réduisant à néant l'intérêt d'un cache D-NUCA. Si on économise quelques microsecondes de temps d'accès en passant d'un cache UCA à un cache S-NUCA, ce n'est pas pour les perdre en passant à un D-NUCA. La majorité des caches D-NUCA sont donc en cours de recherche, mais ne sont pas utilisés en pratique. ==La tolérance aux erreurs des caches== Une mémoire cache reste avant tout une mémoire RAM, bien que ce soit de la SRAM. Elle n'est pas parfaite et est donc sujette à des erreurs, qui peuvent inverser un bit ou l'effacer. De telles erreurs sont liées à des rayons cosmiques très énergétiques, à des particules alpha produites par le packaging ou le métal deu circuit intégré, peu importe : l'essentiel est qu'ils inversent parfois un bit. Les mémoires modernes savent se protéger contre de telles erreurs, en utilisant trois moyens. ===Les mémoires caches ECC et à bit de parité=== Le premier moyen est l'usage de codes correcteurs d'erreurs, qui ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Les bits ajoutés dépendent de la donnée mémorisée dans le byte, et servent à détecter une erreur, éventuellement à la corriger. Le cas le plus simple ajoute un simple bit de parité pour chaque byte et se contente de détecter les erreurs dans les corriger. Les autres codes ECC permettent eux de corriger des erreurs, mais ils demandent d'ajouter au moins deux bits par byte, ce qui a un cout en circuit plus élevé. Un simple bit de parité permet de détecter qu'un bit a été inversé, mais ne permet pas de corriger l'erreur. En soi, ce n'est pas un problème. Si une erreur est détectée, on considère que la ligne de cache est invalide. Le cache gère la situation comme un défaut de cache et va chercher la donnée valide en mémoire RAM. Le cout en circuits est donc faible, mais les défauts de cache sont plus nombreux. Les codes ECC sont eux capables de corriger les erreurs, si elles ne modifient pas trop de bits d'un coup. Par contre, ils utilisent deux à trois bits par octet, ce qui a un cout en circuits loin d'être négligeable. Il y a donc un compromis entre défauts de cache et cout en circuits. La gestion de l'ECC est différente suivant le niveau de cache. Généralement, le cache L1 n'utilise pas l'ECC mais se contente d'un simple bit de parité pour éviter la corruption de ses données. Le cache étant petit, les corruptions de données sont assez rares, et les défauts de cache induits faibles. Il est plus important d'utiliser un code de détection d'erreur simple, rapide, qui ne ralentit pas le cache et n'augmente pas sa latence. Si une ligne de cache est corrompue, il a juste à aller lire la ligne depuis le cache L2, ou un niveau de cache inférieur. Du moins, c'est possible sur le cache en question est un cache inclusif et/ou ''write-through''. Par contre, le niveau de cache L2 et ceux en-dessous utilisent presque systématiquement une mémoire SRAM ECC. La raison principale étant que ce sont des caches assez gros, pour lesquels la probabilité d'une erreur est assez élevée. Plus une mémoire a de bits et prend de la place, plus il y a une chance élevée qu'un bit s'inverse. Et vu que les caches L2/L3/L4 sont par nature plus lents et plus gros, ils peuvent se permettre le cout en performance lié à l'ECC, idem pour le cout en circuit. Sans compter qu'en cas d'erreur, ils doivent aller lire la ligne de cache originelle en mémoire RAM, ce qui est très lent ! Mieux vaut corriger l'erreur sur place en utilisant l'ECC. ===L'usage du ''memory scrubbing'' sur les caches=== La plupart des erreurs ne changent qu'un seul bit dans un byte, mais le problème est que ces erreurs s'accumulent. Entre deux accès à une ligne de cache, il se peut que plusieurs erreurs se soient accumulées, ce qui dépasse les capacités de correction de l'ECC. Dans ce cas, il existe une solution appelée le ''memory scrubbing'', qui permet de résoudre le problème au prix d'un certain cout en performance. Pour rappel, l'idée est de vérifier les lignes de caches régulièrement, pour éviter que les erreurs s'accumulent. Par exemple, on peut vérifier chaque ligne de cache toutes les N millisecondes, et corriger une éventuelle erreur lors de cette vérification. En faisant des vérifications régulières, on garantir que les erreurs n'ont pas le temps de s'accumuler, sauf en cas de malchance avec des erreurs très proches dans le temps. Il ne s'agit pas d'un rafraichissement mémoire, car les SRAM ne s'effacent pas), mais ça a un effet similaire. Et évidemment, le ''memory scrubbing'' a un cout en performance. On peut faire une comparaison avec le rafraichissement mémoire : les rafraichissement réguliers réduisent les performances, car cela fait des accès en plus. Des accès qui sont de plus timés à des instants bien précis qui ne sont pas forcément les plus adéquats. Il est possible qu'un rafraichissement ait lieu en même temps qu'un accès mémoire et le rafraichissement a la priorité, ce qui réduit les performances. La même chose arrive avec les vérifications du ''memory scrubbing''. Malgré tout, la technique a été utilisée sur les caches de certains processeurs commerciaux, dont des processeurs AMD Athlon et Athlon 64. Elle est surtout utilisable sur les caches L2/L3, pour lesquels le cout du pseudo-rafraichissement est acceptable. ==Un exemple de cache : le cache d'instruction== La grande majorité des processeurs utilise deux caches L1 séparés : un '''cache d'instructions''' dédié aux instructions, et un autre pour les données. Une telle organisation permet de charger une instruction tout en lisant une donnée en même temps. Notons que seul le cache L1 est ainsi séparé entre cache de données et d'instructions. Le cache d’instruction se situe en théorie entre l'unité de chargement et l'unité de décodage. En effet, ce cache prend en entrée une adresse et fournit une instruction. L'adresse est fournie par le ''program counter'', l'instruction est envoyée dans l'unité de décodage. Le cache se situe donc entre les deux. Le cache de données L1 est connecté au chemin de données, et notamment aux unités de communication avec la mémoire, pas au séquenceur. [[File:Caches L1 et positions dans le processeur.png|centre|vignette|upright=2.5|Caches L1 et positions dans le processeur]] Les deux caches sont reliés au processeur par des bus séparés, l'ensemble ressemble à une architecture Harvard, mais où les caches remplacent les mémoires RAM/ROM. Le cache d'instruction prend la place de la mémoire ROM et le cache de données prend la place de la mémoire RAM. Évidemment, il y a des niveaux de caches en dessous des caches de données/instruction, et ceux-ci contiennent à la fois données et instructions, les deux ne sont pas séparées dans des mémoires/caches séparés. Raison pour laquelle l'ensemble est appelé une '''architecture Harvard modifiée'''. Architecture Harvard, car l'accès aux données et instructions se font par des voies séparées pour le processeur, modifiée car la séparation n'est effective que pour le cache L1 et pas les autres niveaux de cache, et encore moins la RAM. Sur les processeurs modernes, il arrive très souvent que le processeur doive charger une instruction et lire/écrire une donnée en même temps. Et à vrai dire, c'est la règle plus que l'exception. L'usage d'une architecture Harvard modifiée permet cela très facilement : on peut accéder au cache d'instruction via un bus, et au cache de donnée avec l'autre ===Pourquoi scinder le cache L1 en cache d'instruction et de données=== L'usage d'un cache d’instruction séparé du cache de données est à contraster avec l'usage d'un cache L1 multiport unique, capable de mémoriser à la fois instructions et données. Les deux solutions sont possibles ont été utilisées. Les premiers processeurs avaient un cache L1 unique et multiport, mais ce n'est plus le cas sur les processeurs modernes, car les contraintes ne sont pas les mêmes. Le compromis à faire est celui entre deux petits caches rapides et un gros cache plus lent. Pour rappel, plus un cache est petit, plus il est rapide et chauffe moins. Donc au lieu d'utiliser, par exemple, un gros cache lent de 64 Kibioctets, on utilise deux caches de 32 kibioctets, plus rapides. La capacité totale est la même, mais le temps d'accès plus faible. En termes de temps d'accès, la meilleure solution est celle des deux caches simple port. Mais pour ce qui est de l'économie de circuits, c'est moins évident. Entre deux mémoires simple port et une mémoire multiport, la différence en termes de transistors est ambigüe et dépend de la capacité des caches. La différence est surtout notable pour les gros caches, moins pour les petits caches. Il faut aussi tenir compte de la capacité effective. Avec deux caches séparés, la répartition de la capacité du cache L1 est fixée une bonne fois pour toutes. Par exemple, avec un cache d'instruction de 32 KB et un cache de données de 32 KB, impossible d'allouer 40 KB aux données et 20 aux instructions. Alors qu'avec un cache L1 unique de 64 KB, on pourrait le faire sans soucis. La répartition se fait naturellement, en fonction de la politique de remplacement du cache et est proche de l'optimal. C'est là un désavantage des caches d'instructions/données séparés : une capacité effective moindre. Tout cela explique pourquoi le cache L1 est le seul à être ainsi scindé en deux, avec une séparation entre instructions et données : les contraintes au niveau du cache L1 et L2 ne sont pas les mêmes. Pour les caches L1, le temps d'accès est plus important que la capacité, ce qui favorise les caches séparés. Par contre, pour les caches L2/L3/L4, le temps d'accès n'est pas déterminant, alors que la capacité effective et l'économie en circuits sont significatives. ===La connexion des caches L1 avec le cache L2=== Pour les connexions avec le cache L2, tout dépend du processeur. Certains utilisent un cache L2 multiport, qui permet aux deux caches L1 de lire ou écrire dans le cache L2 simultanément. [[File:Cache d'instructions.png|centre|vignette|upright=1.5|Cache d'instructions.]] Si le cache L2 ne gère pas les accès simultanés, il n'y a qu'un seul bus relié aux caches L1 et au cache L2. On doit effectuer un arbitrage pour décider quel cache a la priorité, chose qui est réalisé par un circuit d'arbitrage spécialisé. [[File:Circuit d'arbitrage du cache.png|centre|vignette|upright=1.5|Circuit d'arbitrage du cache.]] Généralement, les caches d'instructions peuvent se permettre d'être plus petits que les caches de données, car les programmes sont souvent plus petits que les données manipulées. Songez que des programmes de quelques mébioctets peuvent parfois remplir la RAM avec plusieurs gibioctets de données. Lancez votre navigateur internet et ouvrez une page web un peu chargée, pour vous en convaincre ! ===Les spécificités du cache d'instruction : lecture seule, bloquant, etc=== Les instructions sont rarement modifiées ou accédées en écritures, contrairement aux données. Et cela permet d'utiliser un cache simplifié pour les instructions. Autant un cache généraliste doit permettre les lectures et écritures depuis le processeur (avec les échanges avec la RAM), autant un cache d'instruction peut se contenter des lectures provenant du CPU et des échanges avec la RAM. Le cache d'instructions est donc très souvent en « lecture seule » : le processeur ne peut pas écrire dedans, mais juste le lire ou charger des instructions dedans. Un cache d'instruction est donc plus simple qu'un cache pour les données : on peut retirer les circuits en charge de l'écriture (mais on doit laisser un port d'écriture pour charger les instructions dedans). Le gain en circuits permet d'utiliser un cache d'instruction plus gros ou au contraire de laisser de la place pour le cache de données. Le gain en termes de capacité compense alors un peu les inconvénients des caches séparés. Par contre, cela complique la gestion du code automodifiant, c'est-à-dire des programmes dont certaines instructions vont aller en modifier d'autres, ce qui sert pour faire de l'optimisation ou est utilisé pour compresser ou cacher un programme (les virus informatiques utilisent beaucoup de genre de procédés). Quand le processeur exécute ce genre de code, il ne peut pas écrire dans ce cache L1 d'instructions, mais doit écrire dans le cache L2 ou en RAM, avant de recharger les instructions modifiées dans le cache L1. Cela qui prend du temps et peut parfois donner lieu à des erreurs si le cache L1 n'est pas mis à jour. Les algorithmes de remplacement des lignes de cache optimaux pour les données ne le sont pas pour les instructions, de même que la taille optimale du cache, la taille des lignes de cache optimale, ou même les algorithmes de préchargement. Par exemple, pour le remplacement des lignes de cache, un simple algorithme LRU est presque optimal pour les instructions, autant il peut donner de mauvaises performances quand on manipule beaucoup de tableaux. Cela justifie d'utiliser des caches spécialisés pour chacune. On peut adapter le cache d'instruction à son contenu, ce qui le rend plus rapide ou plus petit à performance égale. Les caches d'instructions sont généralement des caches bloquants. Il ne servirait à rien de rendre un cache d'instruction non-bloquant, le cout en circuits ne se traduirait pas par une augmentation significative des performances. À l'opposé, les caches de données sont non-bloquants sur les architectures modernes, pour des raisons de performance. Ce qui rend la séparation assez intéressante, les deux caches ayant des besoins différents et des implémentations différentes, cela permet d'optimiser le cout en transistors des caches. ===L'impact du cache d'instruction sur les performances=== Sur les architectures conventionnelles, le cache d'instruction a plus d'impact sur les performances que le cache de données. La raison principale est que les instructions ont une meilleure localité spatiale et temporelle que pour les données. Pour la localité spatiale, les instructions consécutives se suivent en mémoire, alors que rien ne garantit que des données utilisées ensemble soient regroupées en mémoire. Pour localité temporelle, elle est très variable pour les données, mais très courante pour les instructions du fait de l'usage fréquent des boucles et des fonctions. : La présence de branchements atténue la localité temporelle des instruction, sauf que la majorité des branchements sautent à un endroit très proche, seuls les appels de fonction brisent la localité spatiale. La conséquence est qu'il arrive que certains CPU aient un cache L1 d'instruction plus gros que celui pour les données. On parle alors de '''cache L1 asymétriques'''. Un exemple est celui des processeurs AMD de microarchitecture Zen, dont le cache d'instruction était deux fois plus gros que le cache de données. Leur cache d'instruction faisait 64 kibioctets, contre seulement 32 pour le cache de données. D'ailleurs, il existe des processeurs assez extrêmes qui se contentent d'un cache d'instruction unique, sans cache de données. C'est le cas sur les processeurs vectoriels ou les GPU que nous verrons dans les chapitres de fin de ce wikilivres. De tels processeurs sont spécialisés dans la manipulation de tableaux de données, traitement qui a une faible localité temporelle. En conséquence, utiliser un cache de données n'est pas vraiment utile, voire peu être contreproductif, alors qu'un cache d’instruction fonctionne parfaitement. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Compléments sur les mémoires de masse | prevText=Compléments sur les mémoires de masse | next=Le préchargement | nextText=Le préchargement }} </noinclude> pr26i0247gkk7ijoicc2tgcp5q0v5gk 765132 765131 2026-04-26T16:46:44Z Mewtow 31375 /* Le contrôleur de cache 82385 pour les CPU Intel 386 */ 765132 wikitext text/x-wiki Le cache est une mémoire intercalée entre la mémoire et un processeur, plus rarement à l'intérieur d'un périphérique. Il est souvent fabriquée avec de la mémoire SRAM, parfois avec de l'eDRAM. Sans lui, on se croirait à l'âge de pierre tellement nos PC seraient lents ! En effet, la mémoire est très lente comparée au processeur. Le temps mis pour accéder à la mémoire est du temps durant lequel le processeur n'exécute pas d'instruction (sauf cas particuliers impliquant un pipeline). Pour diminuer ce temps d'attente, il a été décidé d'intercaler une mémoire petite mais rapide, entre le processeur et la mémoire. Ainsi, le processeur accède à un cache très rapide plutôt qu'à une RAM beaucoup plus lente. ==L'accès au cache== Le cache contient une copie de certaines données présentes en RAM. La copie présente dans le cache est accessible bien plus rapidement que celle en RAM, vu que le cache est plus rapide. Mais seule une petite partie de ces données sont copiées dans le cache, les autres données devant être lues ou écrites dans la RAM. Toujours est-il que le cache contient une copie des dernières données accédées par le processeur. Une donnée est copiée dans la mémoire cache quand elle est lue ou écrite par le processeur. Le processeur conserve une copie de la donnée dans le cache après son premier accès. Les lectures/écritures suivantes se feront alors directement dans le cache. Évidemment, au fur et à mesure des accès, certaines données anciennes sont éliminées du cache pour faire de la place aux nouveaux entrants, comme nous le verrons plus tard. [[File:Principe d'une mémoire cache.gif|centre|vignette|upright=2|Principe d'une mémoire cache.]] La mémoire cache est invisible pour le programmeur, qui ne peut pas déceler celles-ci dans l'assembleur. Les accès mémoire se font de la même manière avec ou sans le cache. La raison à cela est que le cache intercepte les accès mémoire et y répond s'il en a la capacité. Par exemple, si le cache intercepte une lecture à une adresse et que le contenu de cette adresse est dans le cache, le cache va outrepasser la mémoire RAM et la donnée sera envoyée par le cache au lieu d'être lue en RAM. par contre, si un accès se fait à une adresse pour laquelle le cache n'a pas la donnée, alors l'accès mémoire sera effectué par la RAM de la même manière que si le cache n'était pas là. [[File:Accès au cache.png|centre|vignette|upright=2|Accès au cache]] ===Les succès et défauts de caches=== Tout accès mémoire est intercepté par le cache, qui vérifie si la donnée demandée est présente ou non dans le cache. Si la donnée voulue est présente dans le cache, on a un '''succès de cache''' (''cache hit'') et on accède à la donnée depuis le cache. Sinon, c'est un '''défaut de cache''' (''cache miss'') et on est obligé d’accéder à la RAM. Les défauts de cache peuvent avoir plusieurs origines. Tout ce qu'il faut savoir est que lorsque le processeur accède à une donnée ou une instruction pour la première fois, il la place dans la mémoire cache car elle a de bonnes chances d'être réutilisée prochainement. La raison à cela est qu'un programme a tendance à réutiliser les instructions et données qui ont été accédées dans le passé : c'est le ''principe de localité temporelle''. Bien évidement, cela dépend du programme, de la façon dont celui-ci est programmé et accède à ses données et du traitement qu'il fait, mais c'est souvent vrai en général. La première cause des défauts de cache est liée à la taille du cache. À force de charger des données/instructions dans le cache, le cache fini par être trop petit pour conserver les anciennes données. Le cache doit bien finir par faire de la place en supprimant les anciennes données, qui ont peu de chances d'être réutilisées. Ces anciennes données éliminées du cache peuvent cependant être accédées plus tard. Tout prochain accès à cette donnée mènera à un cache miss. C'est ce qu'on appelle un ''Capacity Cache Miss'', ou encore '''défaut de capacité'''. Les seules solutions pour éviter cela consistent à augmenter la taille du cache ou à optimiser le programme exécuté (voir plus bas). Une autre raison pour un défaut est donc la suivante. Lorsqu'on exécute à une instruction ou qu'on accède à donnée pour la première fois, celle-ci n'a pas encore été chargée dans le cache. Le défaut de cache est inévitable : ce genre de cache miss s'appelle un ''Cold Miss'', ou encore un '''défaut à froid'''. De tels défauts sont presque impossibles à éliminer, sauf à utiliser des techniques de préchargement qui chargent à l'avance des données potentiellement utiles. Ces méthodes de préchargement se basent sur le principe de localité spatiale, à savoir le fait que les programmes ont tendance à accéder à des données proches en mémoire. Pour donner un exemple, les instructions d'un programme sont placées en mémoire dans l’ordre dans lequel on les exécute : la prochaine instruction à exécuter est souvent placée juste après l'instruction en cours (sauf avec les branchements). Quand on accède à une donnée ou une instruction, le cache peut précharger les données adjacentes pour en profiter. Nous parlerons de ces techniques de préchargement dans un chapitre dédié, vers la fin du cours. ===Le fonctionnement du cache, vu du processeur=== Vu du processeur, le cache prend en entrée toutes les informations nécessaires pour effectuer un accès mémoire : des signaux de commande, une adresse et la donnée à écrire si besoin. Tout cela est passé en entrée du cache, celui-ci répondant aux accès mémoire via divers bits de contrôles, que le processeur peut lire à souhait. Le cache fournit aussi la donnée à lire, pour les lectures, sur une sortie, connectée directement au bus mémoire/processeur. Globalement, le cache a une capacité limitée, mais il prend en entrée des adresses complètes. Par exemple, sur un processeur 64 bits, le cache prend en entrée des adresses de 64 bits (sauf si optimisations), même si le cache en question ne fait que quelques mébioctets. Les caches sont souvent des mémoires multiports, surtout sur les processeurs récents. Les caches simple port sont rares, mêmes s'ils existent et ont existé par le passé. les caches double port sont eux plus fréquents, et ont généralement un port d'écriture séparé du port de lecture. Mais les caches récents ont plusieurs ports de lecture/écriture et sont capables de gérer plusieurs accès mémoire simultanés. Les données présentes dans le cache sont (pré)chargées depuis la mémoire, ce qui fait que toute donnée dans le cache est la copie d'une donnée en mémoire RAM. Le cache doit faire la correspondance entre une donnée du cache et l'adresse mémoire correspondante. Du point de vue du fonctionnement, on peut voir le cache comme une sorte de table de correspondance, qui mémorise des données, chacune étant associée à son adresse mémoire. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Cela vaut du point de vue du processeur, le fonctionnement interne du cache étant quelque peu différent selon le cache. Il existe des caches dont le fonctionnement interne est bien celui d'une table de correspondance matérielle, d'autres qui sont beaucoup plus optimisés. [[File:Fonctionnement d'une mémoire associative à correspondance.png|centre|vignette|upright=2|Fonctionnement simplifié d'une mémoire cache : les adresses sont dans la colonne de gauche, les données sont dans la colonne de droite. On voit qu'on envoie l'adresse au cache, que celui-ci répond en renvoyant la donnée associée.]] ==La performance des mémoires caches== L'analyse de la performance des mémoires caches est plus riche pour celle des autres mémoires. Sa performance dépend de beaucoup de paramètres, mais on peut cependant citer les principaux. Les deux premiers sont tout bonnement sa latence et son débit, comme pour n'importe quelle autre mémoire. La latence est plus importante que son débit, car le processeur est généralement plus rapide que le cache et qu'il n'aime pas attendre. Mais le critère le plus important pour un cache est sa capacité à empêcher des accès mémoire, son efficacité. Plus les accès mémoire sont servis par le cache au lieu de la RAM, meilleures seront les performances. Pour résumer, la performance d'un cache est surtout caractérisée par deux métriques : le taux de défaut, qui correspond à l’efficacité du cache, et la latence du cache. ===Le taux de succès/défaut=== Le '''taux de succès''' (hit ratio) est un premier indicateur des performances du cache, mais un indicateur assez imparfait. C'est le pourcentage d'accès mémoire qui ne déclenchent pas de défaut de cache. Plus il est élevé, plus le processeur accède au cache à la place de la RAM et plus le cache est efficace. Certains chercheurs préfèrent utiliser le '''taux de défauts''', à savoir le pourcentage d'accès mémoire qui entraînent un défaut de cache. Plus il est bas, meilleures sont les performances. Le taux de défaut est relié au taux de succès par l'équation <math>T_\text{succes} = 1 - T_\text{defaut}</math>. Par définition, il est égal à : : <math>\text{Taux de défauts de cache} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d’accès mémoires}}</math> Plutôt que de comparer le nombre de défauts/succès de cache au nombre d'accès mémoire, il est aussi possible de diviser le nombre de défauts par le nombre total d'instructions. On obtient alors le '''taux de défauts/succès par instruction''', une autre métrique utile. Par définition, elle est égale à : : <math>\text{Taux de défauts par instruction} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d'instructions}} = \text{Taux de défauts de cache} \times \frac{\text{Nombre d’accès mémoires}}{\text{Nombre d'instructions}}</math> Si certains défauts de cache sont inévitables quel que soit le cache, comme les défauts à froids, mentionnés plus haut, d'autres défauts peuvent être évités en augmentant la capacité du cache. C'est le cas des défauts de capacité qui sont causés par un accès à une donnée qui a été éliminée du cache faute de place. Plus le cache est gros, moins il a de chances d'être rempli, moins il doit rapatrier de données, plus son taux de succès augmente. Mais nous reviendrons sur le lien entre taille du cache et taux de défaut plus bas. Le taux de succès ne dépend pas que du cache, mais aussi de la conception des programmes exécutés. Une bonne utilisation du cache (ainsi que de la mémoire virtuelle) repose sur le programmeur qui doit prendre en compte les principes de localités dès la conception de ses programmes. Par exemple, un programmeur peut parfaitement tenir compte du cache au niveau de son algorithme : on peut citer l'existence des algorithmes ''cache oblivious'', qui sont conçus pour être optimaux quelle que soit la taille du cache. Le programmeur peut aussi choisir ses structures de données de manière à améliorer la localité. Par exemple, un tableau est une structure de donnée respectant le principe de localité spatiale, tandis qu'une liste chaînée ou un arbre n'en sont pas (bien qu'on puisse les implémenter de façon à limiter la casse). D'autres optimisations sont parfois possibles : par exemple, le sens de parcours d'un tableau multidimensionnel peut faire une grosse différence. Cela permet des gains très intéressants pouvant se mesurer avec des nombres à deux ou trois chiffres. Je vous recommande, si vous êtes programmeur, de vous renseigner le plus possible sur les optimisations de code ou algorithmiques qui concernent le cache : il vous suffira de chercher sur Google. Il y a une citation qui résume bien cela, prononcée par un certain Terje Mathisen. Si vous ne le connaissez pas, cet homme est un vieux programmeur (du temps durant lequel on codait encore en assembleur), grand gourou de l’optimisation, qui a notamment travaillé sur le moteur de Quake 3 Arena. {{BlocCitation|Almost all programming can be viewed as an exercise in caching.|auteur=Terje Mathisen}} ===La latence moyenne d'un cache=== Le temps mis pour lire ou écrire une donnée varie en présence d'un cache. Certaines lectures/écritures vont atterrir directement dans le cache (succès) tandis que d'autres devront aller chercher leur contenu en mémoire RAM (défaut de cache). Dans tous les cas, qu'il y ait défaut ou non, le cache sera consulté et mettra un certain temps à répondre, égal au temps de latence du cache. Tous les accès mémoires auront donc une durée au moins égale au temps de latence du cache, qui sera notée <math>T_c</math>. En cas de succès, le cache aura effectué la lecture ou l'écriture, et aucune action supplémentaire n'est requise. Ce qui n'est pas le cas en cas de défaut : le processeur devra aller lire/écrire la donnée en RAM, ce qui prend un temps supplémentaire égal au temps de latence de la mémoire RAM. Un défaut ajoute donc un temps, une pénalité, à l'accès mémoire. Dans ce qui suivra, le temps d'accès à la RAM sera noté <math>T_m</math>. Fort de ces informations, nous pouvons calculer le temps de latence moyen d'un accès mémoire, qui est la somme du temps d'accès au cache (pour tous les accès mémoire), multiplié par le temps lié aux défauts. On a alors : : <math>T = T_c + \text{Taux de défaut} \times T_m</math> On voit que plus le taux de succès est élevé, plus le temps de latence moyen sera bas, et inversement. Ce qui explique l'influence du taux de succès sur les performances du cache, influence assez importante sur les processeurs actuels. De nos jours, le temps que passe le processeur dans les défauts de cache devient de plus en plus un problème au fil du temps, et gérer correctement le cache est une nécessité, particulièrement sur les processeurs multi-cœurs. Il faut dire que la différence de vitesse entre processeur et mémoire est tellement importante que les défauts de cache sont très lents : alors qu'un succès de cache va prendre entre 1 et 5 cycles d'horloge, un cache miss fera plus dans les 400-1000 cycles d'horloge. Tout ce temps sera du temps de perdu que le processeur aura du mal à mitiger. Autant dire que réduire les défauts de cache est beaucoup plus efficace que d'optimiser les calculs effectués par le processeur (erreur courante chez de nombreux programmeurs, notamment débutants). ===L'impact de la taille du cache sur le taux de défaut et la latence=== Il y a un lien entre taille du cache, taux de défaut, débit binaire et latence moyenne. Globalement, plus un cache est gros, plus il est lent. Simple application de la notion de hiérarchie mémoire vue il y a quelques chapitres. Les raisons à cela sont nombreuses, mais nous ne pouvons pas les aborder ici, car il faudrait que nous sachions comment fonctionne un cache et ce qu'il y a à l'intérieur, ce qui sera vu dans la suite du chapitre. Toujours est-il que la latence moyenne d'un cache assez gros est assez importante. De même, le débit binaire d'un cache diminue avec sa taille, mais dans une moindre mesure. Les petits caches ont donc un gros débit binaire et une faible latence, alors que c'est l'inverse pour les gros caches. Une grande capacité de cache améliore le taux de succès, mais cela se fait au détriment de son temps de latence et de son débit, ce qui fait qu'il y a un compromis assez difficile à trouver entre taille du cache, latence et débit. Il peut arriver qu'augmenter la taille du cache augmente son temps d'accès au point d’entraîner une baisse de performance. Par exemple, les processeurs Nehalem d'Intel ont vus leurs performances dans certains jeux vidéos baisser de 2 à 3 %, malgré de nombreuses améliorations architecturales, parce que la latence du cache L1 avait augmentée de 2 cycles d'horloge. Pour avoir une petite idée du compromis à faire, regardons la relation entre taille du cache et taux de défaut. Il existe une relation approximative entre ces deux variables, appelée la '''loi de puissance des défauts de cache'''. Elle donne le nombre total de défaut de cache en fonction de la taille du cache et de deux autres paramètres. Voici cette loi : : <math>\text{Taux de défauts de cache} \approx K \times \text{Taille du cache}^{- \alpha }</math>, avec <math>K</math> et <math>\alpha</math> deux coefficients qui dépendent du programme exécuté. Le coefficient <math>\alpha</math> est généralement compris entre 0.3 et 0.7, guère plus, et varie suivant le programme exécuté. Précisons que cette loi ne marche que si le cache est assez petit par rapport aux données à utiliser. Pour un cache assez gros et des données très petites, la relation précédente est mise en défaut. Pour s'en rendre compte, il suffit d'étudier le cas extrême où toutes les données nécessaires tiennent dans le cache. Dans ce cas, il n'y a qu'un nombre fixe de défauts de cache : autant qu'il faut charger de données dans le cache. Le nombre de défauts de cache observé dans cette situation n'est autre que le coefficient <math>K</math> de la situation précédente, mais il n'y a aucune dépendance entre taux de défaut et taille du cache. L'origine de cette relation s'explique quand on regarde combien de fois chaque donnée est réutilisée lors de l’exécution d'un programme. La plupart des données finissent par être ré-accédées à un moment ou un autre et il se passe un certain temps entre deux accès à une même donnée. Sur la plupart des programmes, les observations montrent que beaucoup de réutilisations de données se font après un temps très court et qu'inversement, peu de ré-accès se font après un temps inter-accès long. Si on compte le nombre de réutilisation qui ont un temps inter-accès bien précis, on retrouve une loi de puissance identique à celle vue précédemment : : <math>\text{Nombre de réaccès avec un temps inter-accès égal à t} \approx K \times t^{- \beta}</math>, avec t le temps moyen entre deux réutilisations. Le coefficient <math>\beta</math> est ici compris entre 1.7 et 1.3. De manière générale, les coefficients <math>\alpha</math> et <math>\beta</math> sont reliés par la relation <math>\alpha = 1 - \beta</math>, ce qui montre qu'il y a un lien entre les deux relations. Précisons cependant que la loi de puissance précédente ne vaut pas pour tous les programmes informatiques, mais seulement pour la plupart d’entre eux. Il n'est pas rare de trouver quelques programmes pour lesquels les accès aux données sont relativement prédictibles et où une bonne optimisation du code fait que la loi de puissance précédente n'est pas valide. La loi de puissance des défauts de cache peut se démontrer à partir de la relation précédente, sous certaines hypothèses. Si un suppose que le cache est assez petit par rapport aux données, alors les deux relations sont équivalentes. L'idée qui se cache derrière la démonstration est que si le temps entre deux accès à une donnée est trop long, alors la donnée accédée aura plus de chance d'être rapatriée en RAM, ce qui cause un défaut de cache. La chance de rapatriement dépend de la taille du cache, un cache plus gros peut conserver plus de données et a donc un temps avant rapatriement plus long. ==Les lignes de cache et leurs tags== Du point de vue du processeur, les lectures et écritures se font mot mémoire par mot mémoire. Un processeur avec des entiers de 64 bits recoit des données de 64 bits de la part du cache, et y écrit des mots de 64 bits. Mais quand on regarde comment sont stockées les données à l'intérieur du cache, les choses sont différentes. ===Les lignes de cache=== Les données sont mémorisées dans le cache par blocs de plusieurs bytes, d'environ 64 à 256 octets chacun, qui portent le nom de '''lignes de cache'''. Les lignes de cache sont l'unité de stockage que l'on trouve à l'intérieur du cache, mais elles servent aussi d'unité de transaction avec la mémoire RAM. Sur les caches actuels, on transfère les données entre le cache et la RAM ligne de cache par ligne de cache, dans la limite de la taille du bus mémoire. Mais d'autres caches plus anciens permettaient de faire des transferts plus fins. C’est-à-dire qu'on pouvait mettre à jour quelques octets dans une ligne de cache sans avoir à la recopier intégralement depuis ou dans la mémoire RAM. En théorie, on pourrait imaginer des caches où les données sont stockées différemment, où l'unité serait le mot mémoire, par exemple. Par exemple, sur un processeur 64 bits, on aurait une ligne de cache de 64 bits. Cela aurait l'avantage de la simplicité : les transferts entre le processeur et la mémoire serait de même taille, l'intérieur du cache ressemblerait à son interface montrée au processeur. Mais cela aurait quelques défauts qui sont compensés par l'organisation en lignes de cache de grande taille. Le premier avantage des lignes de cache est lié à la localité spatiale, la tendance qu'on les programmes à accéder à des données proches les unes des autres. Des accès mémoires consécutifs ont tendance à se faire à des adresses proches, qui ont de bonnes chances d'être dans la même ligne de cache. Et des accès consécutifs à une même ligne de cache sont plus rapides que des accès à deux lignes distinctes. Une autre raison est tout simplement que cela simplifie considérablement la circuiterie du cache. Pour une capacité identique, il vaut mieux avoir peu de lignes de cache assez grosses, que beaucoup de petites lignes de cache. La raison est que les circuits du cache, comme le décodeur, l'encodeur et autres, ont moins de sorties et sont donc plus simples. ===L'alignement des lignes de cache=== Les lignes de cache sont des blocs de plusieurs dizaines à centaines de bytes, dont la taille est presque toujours une puissance de deux. De plus, les lignes de cache sont alignées en mémoire. Nous avions déjà abordé la notion d'alignement mémoire dans un chapitre précédent, mais le concept d'alignement des lignes de cache est quelque peu différent. Quand nous avions parlé d'alignement auparavant, il s'agissait de l'alignement des données manipulées par le processeur, qui faisait partie du jeu d'instruction du processeur. Ici, nous parlons d'un alignement totalement différent, invisible pour le programmeur, sans lien avec le jeu d’instruction. Voyons de quoi il retourne. Concrètement, cela veut dire que du point de vue du cache, la RAM est découpée en blocs qui font la même taille qu'une ligne de cache, aux positions prédéterminées, sans recouvrement entre les blocs. Par exemple, pour un cache dont les lignes de cache font 256 octets, le premier bloc est à l'adresse 0, le second est 256 octets plus loin, c'est à dire à l'adresse 256, le troisième à l'adresse 512, la quatrième à l'adresse 768, etc. Une ligne de cache de 256 octets contiendra une donnée provenant d'un bloc de RAM de 256 octets, dont l'adresse est systématiquement un multiple de 256. Il n'est pas possible qu'une ligne de cache contienne un bloc de 256 octets dont l'adresse du premier octet serait l'adresse 64, ou l'adresse 32, par exemple. En clair, les adresses de ces blocs sont des multiples de la taille de la ligne de cache, de la taille des blocs. Cela rappelle les contraintes d'alignement vues dans le chapitre "Le modèle mémoire : alignement et boutisme", mais appliquées aux lignes de cache. L'alignement des lignes de cache a des conséquences pratiques pour la conception des caches. Notons qu'il est en théorie possible d'avoir des caches dont les lignes de cache ne sont pas alignées, mais cela poserait des problèmes majeurs. Il serait en effet possible qu'une donnée soit présente dans deux lignes de cache à la fois. Par exemple, prenons le cas où une ligne de cache de 256 commence à l'adresse 64 et une autre ligne de cache commence à l'adresse 0. L'adresse 128 serait dans les deux lignes de cache ! Et cela poserait des problèmes lors des lectures, mais encore plus lors des écritures. C'est pour éviter ce genre de problèmes que les lignes de cache sont alignées avec la mémoire RAM dans tous les caches existants. L'alignement des lignes de cache est une chose que les programmeurs doivent parfois prendre en compte quand ils écrivent du code ultra-optimisé, destiné à des programmes demandant des performances extrêmes. Il arrive que les contraintes d'alignement posent des problèmes. Nous avions vu dans le chapitre sur le boutisme et l'alignement qu'il valait mieux gérer l'alignement des variables des structures de données, pour éviter les accès non-alignés avec le bus mémoire. La même chose est possible, mais pour l'alignement avec des lignes de cache. Typiquement, l'idéal est que, pour une structure de donnée, on puisse en mettre un nombre entier dans une ligne de cache. Ou alors, si la structure est vraiment grande, que celle-ci occupe un nombre entier de lignes de cache. Si ce n'est pas le cas, il y a un risque d'accès non-alignés, c'est à dire qu'une structure se retrouve à cheval sur deux lignes de cache, avec les défauts que cela implique. ===Le tag d'une ligne de cache=== Plus haut, nous avions dit que le cache mémorise, pour chaque ligne de cache, l'adresse RAM associée. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Mais du fait de l'organisation du cache en lignes de cache de grande taille, qui sont de plus alignées en mémoire, il faut nuancer cette affirmation. Le cache ne mémorise pas la totalité de l'adresse, ce qui serait inutile. L'alignement des lignes de cache en RAM fait que les bits de poids faible de l'adresse ne sont pas à prendre en compte pour l'association adresse-ligne de cache. Dans ces conditions, on mémorise seulement la partie utile de l'adresse mémoire correspondante, qui forme ce qu'on appelle le '''tag'''. Le reste de l'adresse indique quelle est la position de la donnée dans la ligne de cache. Par exemple, prenons le cas où le processeur gère des nombres entiers de 64 bits (8 octets) et des lignes de cache de 128 octets : chaque ligne de cache contient donc 16 entiers. Si le processeur veut lire ou écrire un entier bien précis, il doit préciser sa place dans la ligne de cache. Et ce sont les bits de l'adresse mémoire non-inclus dans le cache qui permettent de faire ça. En clair, une adresse mémoire à lire/écrire est interprété par le cache comme la concaténation d'un tag et de la position de la donnée dans la ligne de cache correspondante. [[File:Adressage d'un cache totalement associatif.png|centre|vignette|upright=2|Adressage d'un cache totalement associatif]] Le cache est donc une grande table de correspondance entre tags et lignes de cache. Lors d'un accès mémoire, le cache extrait le tag de l'adresse à lire ou écrire, et le compare avec les tags de chaque ligne de cache. Si une ligne contient ce tag, alors c'est que cette ligne correspond à l'adresse, et c'est un défaut de cache sinon. Lors d'un succès de cache, la ligne de cache est lue depuis le cache et envoyée à un multiplexeur qui sélectionne la donnée à lire dans la ligne de cache. Le fonctionnement est similaire pour une écriture : la donnée à écrire passe dans un démultiplexeur, qui envoie la donnée au bon endroit dans la ligne de cache sélectionnée. [[File:Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.png|centre|vignette|upright=2|Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.]] ===Le contenu d'une ligne de cache=== Dans ce qui va suivre, nous allons considérer que chaque ligne de cache mémorise son tag, les données de la ligne de cache proprement dit, et quelques bits de contrôle annexes qui varient suivant le cache considéré. [[File:Tag d'une ligne de cache.png|centre|vignette|upright=2|Tag d'une ligne de cache.]] Les caches modernes incluent de nombreux bits de contrôle, mais deux d'entre eux sont communs à presque tous les caches modernes : le bit ''Dirty'' et le bit ''Valid''. Le '''bit ''Valid''''' indique si la ligne de cache contient des données valides ou non. Si le bit ''Valid'' est à 0, la ligne de cache est en état valide, à savoir qu'elle contient des données et n'est pas vide. Par contre, si ce bit est à 1, la ligne de cache est invalide et son contenu ne peut pas être lu ou écrit. L'utilité de ce bit est qu'il permet d'effacer une ligne de cache très rapidement : il suffit de mettre ce bit à 0. Il existe des situations où le cache doit être effacé, on dit alors qu'il est invalidé. Une section de ce chapitre sera dédié à l'invalidation du cache. Le '''bit ''Dirty''''' indique qu'une ligne de cache a été modifiée. Par modifiée, on veut dire que le processeur a écrit dedans, qu'il a modifié la ligne de cache. Mais attention : si la donnée a été modifiée dans le cache, la modification n'est pas forcément propagée en mémoire RAM. Le bit ''dirty'' indique si c'est le cas, si l'écriture a été propagée en mémoire RAM. Il précise que la ligne de cache contient des données modifiées, alors que la RAM a des données initiales non-modifiées. Une ligne de cache avec un bit ''dirty'' à 1 est dite ''dirty'', par métonymie. Nous verrons cela en détail dans la section sur les caches ''write-back'' et ''write-through''. Les caches modernes ajoutent des '''bits de détection/correction d'erreur''' dans les bits de contrôle. Pour rappel, les codes de détection/correction d'erreur permettent de se prémunir contre des erreurs matérielles, qui corrompent les données stockées dans une mémoire, ici une mémoire cache. Ils ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Nous reviendrons dessus dans une section ultérieur de ce chapitre. Sur certains caches assez anciens, on pouvait transférer les lignes de caches morceaux par morceaux. Ces caches avaient des lignes de cache divisées en sous-secteurs, ces sous-secteurs étant des morceaux de ligne de cache qu'on pouvait charger indépendamment les uns des autres (mais qui sont consécutifs en RAM). Chaque secteur avait ses propres bits de contrôle, mais le tag était commun à tous les secteurs. [[File:Cache à secteurs.png|centre|vignette|upright=2.5|Cache à secteurs.]] : Dans ce qui va suivre, le terme "ligne de cache" désignera soit un bloc de données copiées depuis la RAM d'une taille de 64/128/256/... octets, soit la concaténation de ces données avec le tag et des bits de contrôle. Les deux définitions ne sont pas équivalentes, mais l'usage a entériné cet abus de langage. Et il faut avouer que cela rend les explications du chapitre plus simples. ==Les instructions de contrôle du cache== Plus haut, nous avions dit que le cache est totalement transparent du point de vue du programmeur. Le cache contient des copies de données en RAM, le programmeur n'a rien à faire pour utiliser le cache correctement. Mais la réalité est que pour des raisons diverses, des processeurs incorporent des '''instructions de contrôle du cache'''. Il s'agit d’instructions qui agissent sur le contenu du cache. Elles existent pour des raisons diverses qu'on détaillera plus bas, mais il s'agit globalement d'une question de performances ou de nécessité pour le système d'exploitation. ===Les instructions de préchargement=== La première instruction de contrôle du cache est une '''instruction de préchargement''', qui demande à charger un bloc de données dans le cache. Elle prend en opérande une adresse mémoire, et le contenu de cette adresse est chargé dans une ligne de cache. Bien sûr, des contraintes d'alignement sont à prendre en compte : on charge un bloc de la même taille qu'une ligne de cache, aligné en mémoire sur la taille du bloc, qui contient l'adresse. L'instruction de préchargement n'est utile que si l'instruction est exécutée bien avant que la donnée ne soit utilisée/lue/écrite. Cela permet de charger une donnée dans le cache à l'avance, d'où le nom de préchargement donné à cette technique. Mais les processeurs modernes gérent des techniques de préchargement automatique, qui ne requièrent pas d'instructions de préchargement. Le préchargement automatique et les instructions de préchargement sont deux solutions complémentaires, mais qui peuvent se marcher sur les pieds. Nous en reparlerons dans le prochain chapitre, qui sera dédié au préchargement automatique. Il faut noter que les instructions de préchargement peuvent être ignorées par le processeur. Sous certaines conditions, le processeur peut décider que l'instruction de préchargement ne sera pas exécutée. Par exemple, il ne va pas précharger une donnée déjà présente dans le cache. Ou encore, si le bus mémoire est occupé, il ne va pas exécuter le préchargement, par manque de ressources matérielles. ===Les instructions d'invalidation et de ''flush''=== Les instructions ''flush'' regroupent deux types d'instructions qui sont souvent utilisées en même temps. Il s'agit des instructions d'invalidation et de nettoyage (''clean''). Les deux termes proviennent de la terminologie ARM, il n'y a pas de terminologie standardisé pour les noms de ces instructions. Dans les grandes lignes, elles permettent de vider le cache, à savoir de rapatrier son contenu en RAM et de réinitialiser le cache à zéro. Elles sont utilisées par le système d'exploitation lors des commutations de contexte, à savoir quand on passe d'un programme à un autre. Elles sont aussi utilisées lors des appels systèmes et routines d'interruption/exception. L'idée est de vider le cache avant d'exécuter un nouveau programme ou une nouvelle routine. Le nouveau programme aura accès à un cache tout propre, les données de l'ancien programme auront été retirée du cache. Les '''instructions ''clean''''' recopient le contenu de la ligne de cache en RAM. Elles forcent la recopie immédiatement de la ligne de cache en mémoire RAM. Pour faire leur travail, elle vérifient si la ligne de cache a été modifiée, avant de la recopier en RAM. Et pour cela, ils vérifient le bit de contrôle ''dirty'', qui est mis à 1 après une première écriture. Si ce bit est à 0, alors pas besoin de recopier la ligne de cache : elle n'a pas été modifiée, la RAM a déjà la bonne copie. Mais s'il est à 1, le cache et la RAM n'ont pas le même contenu, la recopie s'exécute. Les '''instructions d'invalidation''' permettent d'invalider une ligne de cache, à savoir d'effacer son contenu. Nous verrons à quoi servent ces instructions dans la section sur les changement de processus. Invalider une ligne de cache est une opération optimisée : le cache n'est en réalité pas réellement effacé. À la place, le bit ''Valid'' de chaque ligne de cache est juste mis à 0. Il faut noter que l'invalidation efface les lignes de cache sans se préoccuper de leur contenu. Elle se moque qu'une ligne de cache contienne une donnée modifiée, ''dirty'' ou quoique ce soit : la ligne de cache est effacée, point. Il est possible d'invalider une ligne de cache en fournissant une adresse mémoire, mais il est aussi possible d'invalider le cache tout entier. Le choix entre les deux dépend du mode d'adressage de l'instruction d'invalidation. Parfois, il existe une instruction séparée pour invalider tout le cache, et une autre pour invalider une ligne de cache bien précise. Des instructions séparées sont parfois disponibles pour invalider les caches de données et d'instructions, parfois aussi la TLB (un cache qu'on verra dans quelques chapitres). Il est possible de n'invalider que le cache L1, voire le cache L2. Il faut noter que l'invalidation efface tout le cache, mais ne se préoccupe pas de vérifier si les données ont été modifiées dans le cache. Pour certains caches, comme le cache d'instruction, ce n'est pas un problème, vu qu'il est en "lecture seule". Mais pour les caches de données, les données modifiées sont perdues en cas d'invalidation. Heureusement, il existe des instructions d'invalidation qui fusionnent une instruction ''clean'' et une instruction d'invalidation. Il s'agit d''''instructions d'invalidation spéciales'''. ===Les instructions d'optimisation : instructions non-temporelles et écritures optimisées=== Les '''instructions mémoire non-temporelles''' contournent complètement le cache. Par exemple, une lecture peut lire une donnée, mais celle-ci ne sera pas chargée dans le cache, elle passe directement de la RAM vers les registres. Une section entière de ce chapitre sera dédiée au contournement du cache, à savoir aux situations où les accès mémoire doivent passer directement du processeur à la RAM sans passer par le cache. D'autres instructions assez rares incorporent des indications pour le cache. Par exemple, l'instruction ''load last'' des processeurs POWER PC implique que la donnée ne sera utilisée qu'une seule fois. Elle est donc chargée dans le cache, mais la ligne de cache est configurée de manière à être remplacée très rapidement, typiquement avec une valeur de LRU/LFU adéquate. La donnée est bien chargée dans le cache, au cas où elle doive être relue suite à une mauvaise prédiction de branchement ou autre, chose qu'une lecture non-temporelle (qui contourne le cache) ne fait pas. Des indications de ce type sont appelées des '''''cache hint'''''. L''''instruction ''flush''''' permet de préciser qu'une ligne de cache contient une donnée inutile, qui ne sera pas réutilisée par le programme. Pas besoin de la conserver dans le cache, elle peut laisser sa place à des données plus utiles. Or, sans indication, les algorithmes de remplacement d'une ligne de cache risquent de conserver cette donnée trop longtemps, ce qui entraine une certaine pollution du cache par des données inutiles. Une autre instruction est elle beaucoup plus importante : celle de '''pré-allocation sur écriture'''. Elle sert dans le cas où une ligne de cache est complétement écrite. Par exemple, imaginons qu'on veuille écrire dans une portion de mémoire. Si celle-ci n'est pas dans le cache, le processeur va charger une ligne de cache complète depuis la RAM, écrire dans la ligne de cache, puis recopier la ligne de cache modifiée en mémoire RAM. Une écriture en RAM demande donc de faire une lecture et une écriture. Mais les instructions de pré-allocation sur écriture permettent de prévenir qu'une ligne de cache sera intégralement écrite, et qu'il n'y a donc pas besoin de lire celle-ci depuis la RAM. Notons que l'instruction d'écriture qui suit n'est pas une écriture non-temporelle, vu que les données sont écrites dans la ligne de cache, qui est ensuite envoyée en mémoire RAM dès que nécessaire. De plus, les données écrites peuvent ensuite être relue depuis le cache si nécessaire. Enfin, certains processeurs MIPS incorporent une instruction pour modifier le tag d'une ligne de cache. Elles servent à optimiser les copies mémoire, à savoir quand on copie un bloc de données d'un endroit à un autre. L'idée est de charger le bloc de données dans le cache avec une instruction LOAD/PREFETCH, de modifier le tag pour qu'il pointe vers l'adresse à écrire, et de laisser faire le cache pour que l'écriture se fasse en RAM. Mais les contraintes pour utiliser cette instruction sont assez drastiques : les données doivent être alignées sur la taille d'une ligne de cache, le bloc de départ et d'arrivée (l'original versus la copie) ne doivent pas se recouvrir, etc. ==L'associativité des caches et leur adressage implicite== Lorsqu'on souhaite accéder au cache, il faut trouver quelle est la ligne de cache dont le tag correspond à l'adresse demandée. On peut classifier les caches selon leur stratégie de recherche de la ligne correspondante en trois types de caches : totalement associatifs, directement adressés (''direct mapped'') et associatifs par voie. ===Les caches totalement associatifs=== Avec les caches totalement associatifs, toute donnée chargée depuis la mémoire peut être placée dans n'importe quelle ligne de cache, sans aucune restriction. Ces caches ont un taux de succès très élevé, quand on les compare aux autres caches. [[File:Cache totalement associatif.png|centre|vignette|upright=2|Cache totalement associatif.]] Concevoir un cache totalement associatif peut se faire de deux grandes manières différentes. La première consiste tout simplement à combiner une mémoire associative avec une mémoire RAM, en ajoutant éventuellement quelques circuits annexes. La mémoire associative mémorise les tags, alors que la mémoire RAM mémorise les données de la ligne de cache, éventuellement avec quelques bits de contrôle. La ligne de cache est stockée à une adresse A dans la mémoire RAM et son tag est stocké à la même adresse, mais dans la mémoire CAM. Ce faisant, quand on envoie le tag à la mémoire CAM, elle renvoie l'adresse de la ligne de cache dans la mémoire RAM. Cette adresse est alors envoyée directement sur le bus d'adresse de la RAM, et la lecture est effectuée automatiquement. Il faut ajouter quelques circuits annexes pour garantir que les écritures se passent correctement dans les deux mémoires, mais rien de bien terrible. [[File:Cache fabriqué avec une mémoire associative et une RAM.png|centre|vignette|upright=3|Cache fabriqué avec une mémoire associative et une RAM]] Il est cependant possible d'optimiser un tel cache, en fusionnant la mémoire CAM et la mémoire RAM, afin d'éliminer des circuits redondants. Pour comprendre pourquoi, rappelons que les mémoires CAM sont composées d'un plan mémoire, d'un paquet de comparateurs et d'un encodeur. Quant à la mémoire RAM, elle est composée d'un décodeur connecté au plan mémoire. En mettant une CAM suivie d'une RAM, on a un encodeur dont l'entrée est envoyée à un décodeur. [[File:Cache totalement associatif naif.png|centre|vignette|upright=3|Cache totalement associatif naif]] Or, le décodeur réalise l'opération inverse de l'encodeur, ce qui fait que mettre les deux composants à la suite ne sert à rien. On peut donc retirer l'encodeur et le décodeur, et envoyer directement les résultats des comparateurs sur les entrées de commande du plan mémoire de la RAM. [[File:Cache totalement associatif optimisé.png|centre|vignette|upright=2|Cache totalement associatif optimisé]] Avec cette méthode, les circuits du cache ressemblent à ce qui illustré ci-dessous. Le tag est envoyé à chaque ligne de cache. Le tag envoyé est alors comparé avec le Tag contenu dans chaque ligne de cache, comme c'est le cas sur les mémoires associatives. Si une ligne de cache matche avec le tag envoyé en entrée, la ligne pour laquelle il y a eu une égalité est alors connectée sur les lignes de bit (''bitlines''). Cela est réalisé par un circuit commandé par le comparateur de la ligne de cache. Il ne reste plus qu'à sélectionner la portion de la ligne de cache qui nous intéresse, grâce à un paquet de multiplexeurs. Cela permet d'effectuer une lecture ou écriture, mais il faut aussi préciser si il y a eu un défaut de cache ou un succès. Un succès de cache a lieu quand au moins des comparaisons est positive, alors que c'est un défaut de cache sinon. En clair, détecter un succès de cache demande juste de connecter une porte OU à plusieurs entrées à tous les comparateurs. [[File:Organisation générale d'un cache totalement associatif.png|centre|vignette|upright=2|Organisation générale d'un cache totalement associatif.]] ===Les caches directement adressés=== Les caches directement adressés peuvent être vus comme un cache totalement associatif auquel on aurait ajouté des restrictions assez drastiques. Plus haut, on a vu qu'un cache totalement adressé est équivalent à la combinaison d'une CAM avec une RAM. La mémoire CAM prend en entrée un Tag et traduit celui-ci en une adresse qui commande la mémoire RAM interne au cache. Dans ce qui suit, l'adresse interne au cache sera appelé l''''indice''' pour éviter toute confusion. [[File:Cache hash table - 2.png|centre|vignette|upright=2|Fonctionnement interne du cache, expliquée sous forme abstraite, en utilisant la notion d'indice interne au cache.]] Les caches directement adressés cherchent à remplacer la mémoire CAM par un circuit combinatoire. Ce circuit traduit le Tag en indice, mais est beaucoup plus simple qu'une mémoire CAM. Mais qui dit circuit plus simple dit circuit plus limité. Un circuit combinatoire n'est pas aussi versatile que ce qui est permis avec une mémoire CAM. En conséquence, une restriction majeure apparait : toute adresse mémoire est associée dans une ligne de cache prédéfinie, toujours la même. L'association entre ligne de cache et adresse mémoire est faite par le circuit combinatoire, et ne peut pas changer. Les concepteurs de caches s'arrangent pour que des adresses consécutives en mémoire RAM occupent des lignes de cache consécutives, par souci de simplicité. Tout se passe comme suit la mémoire RAM était découpés en blocs de la même taille que le cache. La première adresse du bloc est associée à la première ligne de cache (celle d'indice 0), la seconde adresse est associée à la seconde adresse du_ bloc, et ainsi de suite. Le tout est illustré ci-dessous. [[File:Cache adressé directement.png|centre|vignette|upright=2|Cache adressé directement.]] Avec cette contrainte, le circuit de traduction de l'adresse en adresse mémoire pour la RAM interne au cache est drastiquement simplifié, et disparait même. Une partie de l'adresse mémoire sert à indiquer la position de la donnée dans le cache, le reste de l'adresse sert encode le tag et la position de la donnée dans le ligne de cache. [[File:Cache line.png|centre|vignette|upright=2|Adresse d'une ligne de cache sur un cache adressé directement.]] Un cache directement adressé est conçu avec une RAM, un comparateur, et un paquet de multiplexeurs. En général, la mémoire RAM stocke les lignes de caches complète. Il arrive que l'on utilise deux mémoires RAM : une pour les tags et une pour les données, mais cette technique augmente le nombre de circuits et de portes logiques nécessaires, ce qui réduit la capacité du cache. L'index à lire/écrire est envoyé sur l'entrée d'adresse de la RAM, la RAM réagit en mettant la ligne de cache sur sa sortie de donnée. Sur cette sortie, un comparateur compare le tag de la ligne de cache lue avec le tag de l'adresse à lire ou écrire. On saura alors si on doit faire face à un défaut de cache. Ensuite, un multiplexeur récupère la donnée à lire/écrire. [[File:Direct mapped cache - french.png|centre|vignette|upright=2|Cache directement adressé.]] L'accès à un cache directement adressé a l'avantage d'être très rapide vu qu'il suffit de vérifier une seule ligne de cache : celle prédéfinie. Mais ces caches ne sont cependant pas sans défauts. Vu que le cache est plus petit que la mémoire, certaines adresses mémoires se partagent la même ligne de cache. Si le processeur a besoin d’accéder fréquemment à ces adresses, chaque accès à une adresse supprimera l'autre du cache : tout accès à l'ancienne adresse se soldera par un défaut de cache. Ce genre de défauts de cache causés par le fait que deux adresses mémoires ne peuvent utiliser la même ligne de cache s'appelle un '''défaut par conflit''' (''conflict miss''). Les défauts par conflit n'existent pas sur les caches totalement associatifs. En conséquence, le taux de succès des caches directement adressés est assez faible comparé aux autres caches. [[File:Cache Block Basic Conflict.svg|centre|vignette|upright=1.5|Exemple de ''Conflict Miss''.]] ===Les caches associatifs par voie=== Les caches associatifs par voie sont un compromis entre les caches directement adressés et les caches totalement associatifs. Pour simplifier, ces caches sont composés de plusieurs caches directement adressés accessibles en parallèle, chaque cache/RAM étant appelé une '''voie'''. Avec ces caches, toute adresse mémoire en RAM est associée à une ligne de cache dans chaque voie. [[File:Cache associatif par voie.png|centre|vignette|upright=2|Cache associatif par voie.]] Le schéma ci-dessous compare un cache directement adressé et un cache associatif à deux voies. On voit que chaque adresse est associée à une ligne de cache bien précise avec un cache directement dressé, et à deux lignes de cache avec un cache associatif à deux voies. L'adresse sera associée à 4 lignes de cache sur un cache associatif à 4 voies, à 8 lignes pour un cache à 8 voies, etc. L'ensemble des lignes de cache associées à une adresse est appelé un '''ensemble'''. [[File:Cache Fill.svg|centre|vignette|upright=2|Comparaison entre un cache directement adressé et un cache associatif à deux voies.]] Sur ces caches, toute adresse est découpée en trois parties : un tag, un index, et un décalage, comme sur les caches directement adressés. Comme vous pouvez le voir, l'organisation est identique à celle d'un cache totalement associatif, à part que chaque ensemble tag-ligne de cache est remplacé par une mémoire RAM qui en contient plusieurs. [[File:Implémentation d'un cache associatif par voie.png|centre|vignette|upright=2|Implémentation d'un cache associatif par voie.]] Le risque de conflits d'accès au cache est donc réduit sur un cache associatif à plusieurs voies, et il est d'autant plus réduit que le cache a de voies. Par contre, leur conception interne fait qu'ils ont un temps d'accès légèrement élevé que les caches directement adressés. Les caches associatifs par voie ont donc un taux de succès et un temps d'accès intermédiaire, situé entre les caches directement adressés et totalement associatifs. Ils sont une sorte de compromis entre réduction des défaut par conflits d'accès au cache et temps d'accès, et complexité des circuits. ==Les optimisations des caches associatifs par voie== Les caches partiellement associatifs regroupent les caches associatifs par voie et directement adressés, ainsi que leurs variantes. En clair : tous les caches qui ne sont pas totalement associatifs. Ils peuvent être optimisés de nombreuses manières, que ce soit pour gagner en performance ou pour économiser de l’énergie. Dans cette section, nous allons voir quelles sont ces optimisations. ===Les caches pseudo-associatifs=== Les caches adressés par voie contiennent une mémoire SRAM par voie. En théorie, les voies sont accédées en parallèles, en même temps, afin de voir si l'on a un succès de cache ou un défaut. Les '''caches pseudo-associatifs''' sont identiques aux caches associatifs par voie, si ce n'est qu'ils vérifient chaque voie une par une. Ils ont été utilisés sur des processeurs commerciaux, un exemple étant l'IBM 370. Là encore, on perd en performance pour gagner en consommation d'énergie. Le temps d'accès dans le meilleur des cas est plus faible pour les caches pseudo-associatifs, mais le pire des cas teste tous les caches avant de tomber sur le bon. Les performances sont donc réduites. Mais la consommation énergétique est meilleure, vu qu'on ne vérifie pas forcément toutes les voies en parallèle. On teste la première voie, éventuellement la seconde, peut-être la troisième, etc. Mais dans le cas général, on ne teste qu'une partie des voies, pas toutes, ce qui donne un gain en termes d'énergie. L'implémentation de caches de ce genre demande que l'on parcoure les voies une par une, en commençant de la première jusqu'à la dernière. Pour cela, un simple compteur suffit. Suivant la valeur du compteur, la voie associée est activée puis accédée. Toute la complexité revient à ajouter un circuit qui prend la valeur du compteur, et active la voie associée, lance un accès mémoire dessus. Vu que les voies sont chacune des caches ''direct mapped'', il suffit pour cela de geler les entrées d'adresse, soit en les déconnectant, soit en utilisant du ''clock gating'' ou de l'évaluation gardée. Les détails d'implémentation, non-cités ici, varient selon le cache. ===La prédiction de voie=== Pour réduire le temps d'accès des caches pseudo-associatifs, certains chercheurs ont inventé la '''prédiction de voie''', qui consiste à faire des paris sur la prochaine voie accédée. L'idée est d'accéder à la voie qui contient la donnée voulue du premier coup, en lisant celle-ci en priorité. Dans son implémentation la plus simple, le cache reste un cache pseudo-associatif. Lors d'un accès au cache, les voies sont toutes parcoures une par une. Par contre, les voies ne sont donc pas parcourues de la première vers la dernière, mais dans un ordre différent. Cette technique permet de mettre en veille les voies sur lesquels le processeur n'a pas parié, ce qui permet de diminuer la consommation énergétique du processeur. C'est plus efficace que d'aller lire plusieurs données dans des voies différentes et de n'en garder qu'une. L'implémentation est assez simple : il suffit d'ajouter un circuit de prédiction de voie,relié au compteur de voie. Une amélioration de la technique fait fonctionner le cache comme un intermédiaire entre cache pseudo-associatif et associatif par voies. L'idée est de chercher la voie prédite en premier, puis de chercher dans toutes les voies en parallèle en cas de défaut de cache. Au lieu d'attendre que les comparaisons de tags donnent leur résultat, le processeur sélectionne automatiquement une voie et configure les multiplexeurs à l'avance. Si le processeur ne se trompe pas, le processeur accède à la donnée plus tôt que prévu. S'il se trompe, le processeur annule la lecture effectuée en avance et recommence en faisant un accès en parallèle aux autres voies. Le compromis entre performance et consommation d'énergie est alors différent. On économise de l'énergie par rapport à un cache associatif par voie, au prix d'une petite perte de performance (doublement des temps d'accès). Mais par rapport à un cache pseudo-associatif, l'économie d'énergie est bien moindre, au prix d'un gain en performance assez manifeste. Prédire quelle voie sera la bonne est assez simple. En vertu du principe de localité, les accès futurs ont des chances de tomber dans les voies les plus fréquemment utilisées ou dans celle plus récemment utilisée. Il suffit de retenir la voie la plus récemment accédée dans un registre, qui sera utilisée comme prédiction. Pour vérifier que la prédiction est correcte, il suffit de comparer le registre et le résultat obtenu après vérification des tags. Cependant, on peut complexifier l'implémentation pour prendre en compte l'adresse à lire/écrire, l'instruction à l'origine de l'accès mémoire ou tout autre paramètre utile. Par exemple, des instructions différentes ont tendance à aller chercher leurs données dans des ensembles différents et la voie à choisir n'est pas la même. Pour cela, il suffit d'utiliser un cache pour stocker la correspondance instruction - voie. Pour plus de simplicité, la mémoire cache des prédictions est parfois remplacée par une RAM, qui est adressée : * soit par le program counter de l'instruction à l'origine de l'accès (en réalité, seulement quelques bits de poids faible de l'adresse) ; * soit par l'adresse à accéder (là encore, quelques bits de poids faible) ; * soit (pour les modes d'adressage qui utilisent un registre de base et un décalage) par un XOR entre les bits de poids faible de l'adresse de base et le décalage ; * soit par autre chose. ===La mise en veille sélective des voies=== Les caches associatifs ont tendance à utiliser beaucoup d'énergie, même quand on n'y accède pas. Aussi, certains processeurs détectent quand le cache est peu utilisé et en profitent pour mettre en veille les voies inutilisées. Vous vous demandez certainement ce qui se passe quand une donnée à lire/écrire est dans une voie désactivée. La réponse est que le cache détecte cette situation, car elle déclenche un succès de cache. Les ''tags'' ne sont en effet pas désactivés, seules les données sont mises en veille. L'implémentation est plus simple sur les caches qui séparent les tags et les données dans deux RAM différentes. Cette optimisation marche surtout sur les gros caches, qui ont des chances d'avoir une portion significative d’inutilisée (pas assez de données pour les remplir), donc généralement les caches L3/L4. Par exemple, les processeurs d'Intel de microarchitecture Ivy Bridge disposent d'un cache de 8 mébioctets à 16 voies, qu'ils peuvent faire passer à 512 kibioctets si le besoin s'en fait sentir. Quand ces processeurs détectent une faible activité, ils mettent en veille 14 voies et n'en gardent que 2 d'actives. Évidemment, les 14 voies sont vidées avant d'être mises en veille, afin qu'une aucune donnée ne soit perdue. ===Les caches ''skew-associative''=== Vous aurez remarqué que dans une voie, les lignes sont accédées en adressage direct : les défauts par conflit sont possibles sur un cache associatif par voie. Pour éviter cela, certains chercheurs ont créé des '''caches ''skew associative''''' (ou associatifs à biais). Pour faire simple, les index des lignes de cache subissent un petit traitement avant d'être utilisés. Le traitement en question est différent suivant la voie de destination, histoire que deux adresses mémoires avec des index identiques donnent des index différents après traitement. Le traitement en question est souvent une permutation des bits de l'index, qui est différente suivant la voie prise, ou un simple XOR avec un nombre qui dépend de la voie. [[File:Implémentation d'un cache skew associative.jpg|centre|vignette|upright=2|Implémentation d'un cache skew associative.]] ==Les caches splittés (''phased caches'')== Dans cette section, nous allons voir les '''caches splittés''' (''phased caches''), qui sont une variante des caches ''direct-mapped'', dans lequel le cache est accédé en deux étapes consécutives. Il ne s'agit pas des caches pipelinés, que nous verrons dans le chapitre sur les processeurs pipélinés, mais laissons cela à plus tard. Il est possible d'appliquer la même méthode sur un cache associatif par voie, mais il y a des méthodes plus simples, qui permettent là aussi d’accéder au cache en plusieurs étapes consécutives. L'idée est de scinder le cache en deux : une mémoire pour les tags, une autre pour les données de la ligne de cache. Les bits de contrôle peuvent être mis dans l'une ou l'autre SRAM, mais ils sont souvent mis dans la RAM pour les tags. En faisant cela, quelques optimisations deviennent possibles, afin de réduire la consommation énergétique en contrepartie d'une perte de performance. La technique s'implémente différemment pour les caches totalement associatifs et partiellement associatifs. Les caches totalement associatifs splittés sont ceux formés en combinant un cache associatif avec une CAM et une RAM combinée. On envoie l'adresse à lire/écrire à la mémoire associative, elle répond en envoyant une adresse à la mémoire RAM. L'accès se fait donc en deux temps, avec l'adresse dans la RAM comme intermédiaire. Il est possible de séparer physiquement les deux étapes en insérant un registre entre la CAM et la RAM, ce qui permet aussi de pipeliner l'accès. Mais c'est rarement fait en pratique, car le cout en circuit d'une mémoire CAM est trop important. L'équivalent pour un cache totalement associatif optimisé, sans CAM et RAM séparée, est trop gourmande en interconnexions pour être implémentée. Les caches totalement associatifs splittés sont donc très rares, l'auteur ne connait aucun exemple de processeur avec un tel cache. Il existe une technique équivalente pour les caches ''direct-mapped'', mais elle demande une certaine modification du cache. Dans les caches ''direct-mapped'' non-splittés, on trouve une mémoire SRAM dont chaque mot mémoire contient une ligne de cache entière, tag inclus. Dans leurs versions splittés, la SRAM est séparée en deux : une pour les tags, une autre pour les données. Précisons qu'il s'agit bien de deux mémoires SRAM adressables. L'adresse à laquelle accéder est envoyée à la SRAM des tags, puis ensuite à la SRAM des données si besoin. L'idée est d’accéder aux tags pour déterminer s'il y a un succès de cache ou un défaut, et ensuite d'accéder aux données. On n’accède pas aux données en parallèle des tags. Faire cela est évidemment plus lent. En cas de défaut de cache, le temps d'accès est similaire : le tag ne correspond pas, on n'accède pas à la SRAM pour les données. Par contre, vu qu'on n'a pas activé la SRAM pour les données, on économise un peu d'énergie, ce qui réduit la consommation d'énergie. En cas de succès de cache, on accède à la SRAM pour les tags, puis à celle pour les données. Pas d'économie d'énergie à l'horizon, sans compter que le temps d'accès augmente : on accède au cache en deux étapes au lieu de faire les deux accès en parallèle. [[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]] Précisons cependant que ce design peut avoir deux avantages en termes de performance. Premièrement, le temps d'accès au cache est légèrement amélioré en cas de défaut de cache. En effet, la SRAM des tags est assez petite, idem pour celle des données. Leur temps d'accès est donc plus faible que pour une grosse SRAM contenant données et tags. Le gain en temps d'accès est donc un avantage, qui ne se manifeste surtout en cas de défaut de cache. Un autre avantage est que l'accès au cache se pipeline plus facilement, ce qui fait qu'on peut effectuer plusieurs accès simultanés au cache. Mais nous verrons cela dans quelques chapitres. ===Le contrôleur de cache 82385 pour les CPU Intel 386=== Il est important de noter que la séparation entre tags et RAM peut être telle que les deux ne sont pas sur la même puce de silicium ! Voire que les deux sont séparés du processeur ! C'était le cas quand les mémoires caches ont été introduites sur les processeurs grand public, notamment sur les premiers processeurs Intel. La miniaturisation n'avait pas avancé au point où placer un cache dans le processeur était possible. Sur le processeur 386 d'Intel, le cache était un cache splitté, séparé du processeur. Concrètement, le processeur i386 était couplé à un contrôleur de cache Intel 82385 et une mémoire SRAM. Le 82385 contenait les ''tags'' et les bits de contrôle, la SRAM contenait les données, les lignes de cache. Un point important est que les lignes de cache faisaient seulement 32 bits/4 octets, pas plus ! On était loin des lignes de cache actuelles, faisant 64 octets/512 bits. Mais c'était beaucoup plus pratique, vu que le bus système faisait 32 bits de large, idem pour l'interface avec le processeur. Pour intégrer un cache facultatif (certains ordinateurs s'en passaient). Le schéma ci-dessous montre comment le cache s'intégrait avec le bus système. Pour le bus de commande, le cache servait d'intermédiaire : il recevait les commandes et les filtrait suivant les succès/défauts de cache. En cas de succès de cache, les commandes de lecture n'étaient pas envoyées à la mémoire RAM. Les adresses étaient transmises à la fois au cache et au bus système (avec un registre entre le bus système et le processeur). Le bus de donnée était lui connecté à la mémoire SRAM et au processeur, avec des MUX/DEMUX pour faire le choix de la source des lectures. [[File:Controleur de cache 82385 pour l'Intel 386.png|centre|vignette|upright=2.5|Contrôleur de cache 82385 pour l'Intel 386]] Le 82385 surveillait ce qui se passait sur le bus et répondait à la place de la RAM pour certaines lectures. C'était un intermédiaire assez passif, qui se contenait de répondre aux succès et défauts en lecture. Le cache était un cache ''write through'' un peu particulier. En cas de succès de cache pour une écriture, le cache met à jour sa ligne de cache et propage l'écriture en mémoire RAM. Par contre, si une écriture fait un défaut de cache, la donnée n'est pas écrite dans le cache. Le seul moyen pour copier une donnée dans le cache était un défaut pour une lecture. Le 82385 pouvait commander soit un cache ''direct mapped'', soit associatif à deux voies. Le choix entre les deux était le fait d'une entrée : la mettre à 0 indiquait un cache ''direct mapped'', la mettre à 1 forçait un cache à deux voies. La différence entre les deux est que le 82385 était relié à une mémoire SRAM avec un cache ''direct mapped'', deux SRAM pour deux voies. Pour avoir un cache associatif à deux voies, le 82385 devrait gérer deux signaux ''chip select'' pour activer chaque SRAM/voie suivant les besoins. Il avait précisément quatre signaux CS : deux par SRAM, un pour les lectures, un pour les écritures. Notons que les lignes de cache faisaient 32 bits, ce qui pouvait d'obtenir soit avec une SRAM 32 bits, soit avec deux SRAM 16 bits, soit avec 4 SRAM 8 bits. Le 82385 rajoutait 4 sorties, pour masquer chaque octet dans ces 32 bits, qui sont techniquement des signaux ''Output Enable'' pour 4 SRAM 8 bits. [[File:Interface entre le 82385 et la SRAM du cache.png|centre|vignette|upright=2|Interface entre le 82385 et la SRAM du cache. Beaucoup d'entrées et de sorties liées au bus d'adresse ne sont pas représentées.]] Il gérait aussi les accès mémoire non-cacheable, à savoir des accès mémoire qui ne doivent pas être pris en compte par le cache. Il considérait certains accès mémoire comme "à ne pas cacher". Notamment, les accès mémoire à une entrée-sortie ne sont pas cachés. Pour rappel, le processeur utilisait un espace d'adressage séparé pour les entrées-sorties, et utilisait donc un bit IO, qui était utilisé par le 82385 pour savoir si l'accès mémoire doit être caché ou non. Il en est de même pour les accès ayant lieu lors d'une interruption, qui ne passent pas par le cache. Mais au-delà de cette inhibition automatique du cache, le 82385 avait une entrée NCA (''Non Cacheable Access'') : le cache était "désactivé" quand cette entrée était à 1. C'est un peu une sorte de ''chip select'' pour le 82385, limitée aux accès mémoire. Cette entrée permettait de programmer des intervalles d'adresse auxquels ne pas répondre, en utilisant des circuits de décodage d'adresse adaptés. Il avait aussi une entrée X16, qui permettait d'identifier les accès soit à un composant 16 bits. De tels accès ne doivent pas être mis en cache, sans doute parce que cela ne collait pas avec la taille des lignes de cache (32 bits). Et cette entrée permettait d'inhiber ces accès 16 bits d'agir sur le cache, en utilisant le bit du bus de commande adéquat. Le 82385 pouvait être intégré dans un système à deux processeurs, voire plus. Pour cela, chaque processeur avait son propre 82385 et sa SRAM rien qu'à lui. Il n'y avait pas de cache partagé entre les deux processeurs. Par contre, les deux caches étaient reliés au même bus système. Pour qu'ils ne se marchent pas sur les pieds, il y avait des circuits d'arbitrage pour gérer l'accès au bus. Un des deux 82385 était mis en mode maitre, l'autre était en mode esclave. Le 82385 maitre pouvait prendre le contrôle du bus, le 82385 esclave devait demander l'autorisation au premier pour accéder au bus système. Le 82385 gérait une forme limitée de cohérence des caches par invalidation. Dès que le 82385 détectait une prise de contrôle du bus par autre chose que le processeur, il surveillait les adresses transmises sur le bus. En cas de succès de cache, la ligne de cache associée était invalidée. Au-delà de ça, le 82385 avait une entrée FLUSH, qui ordonnait une invalidation totale du cache. Si cette entrée est mise à 1, toutes les lignes de cache sont invalidées. Les ''tags'' sont marqués comme invalides, mais les lignes de cache elles-mêmes ne sont pas touchées. ===L'exemple des processeurs Intel de microarchitecture ''Broadwell''=== Un autre exemple est celui du cache L4 des processeurs Broadwell et de quelques processeurs séparés. Ces processeurs ont une organisation en ''chiplet'' où le processeur incorpore plusieurs puces séparées : une puce pour le processeur proprement dit, une puce nommée ''Crystal Well'' pour le cache L4, et une puce IO pour la communication avec la RAM et la carte mère. Le processeur incorporait un cache L4 de 128 mébioctets, composé de mémoire eDRAM, qui était dispersé entre ''Crystal Well'' et les autres puces. Les données du cache L4 étaient dans ''Crystal Well'', alors que les Tags étaient soit dans le processeur lui-même, soit dans la puce IO ! La puce ''Crystal Well'' était une mémoire DRAM adressable tout ce qu'il y a de plus basique, avec cependant quelques optimisations notables. Par exemple, elle avait deux bus séparés pour l'écriture et la lecture. De plus, elle avait une organisation interne avec 128 banques, contre moins d'une dizaine pour la DDR de l'époque et environ 32 banques pour la DDR5 moderne. Elle contenait aussi quelques circuits pour gérer son rôle de mémoire cache, mais rien en ce qui concerne la gestion des tags eux-mêmes. Sur les processeurs de microarchitecture ''Broadwell'', les tags étaient placés dans le CPU et précisément dans le cache L3. À chaque accès mémoire au cache L3, les tags du cache L4 étaient consultés en parallèle. De fait, l'accès au cache L4 était assez rapide, malgré le fait que les données étaient dans une puce à part. Ajoutons à cela que le processeur et ''Crystal Well'' n'avaient pas la même finesse de gravure ni la même technologie de fabrication. Les tags étaient implémentés avec de la SRAM contre la DRAM pour les données, ce qui fait que la consultation des tags était plus rapide que l'accès aux données. Par la suite, dans certains CPU de microarchitecture ''skylake'', les tags ont été déplacés en-dehors du processeur pour finir dans le contrôleur mémoire. En faisant cela, le cache L4 pouvait être utilisé par autre chose que le processeur, et notamment par la carte graphique intégrée au CPU. Avec ''broadwell'', le fait que les tags étaient consultés en cas d'accès au L3 empêchait au GPU intégré de consulter le cache L4. Mais en déplaçant les tags dans le contrôleur mémoire, ce n'est plus le cas vu que la carte graphique a aussi accès au bus mémoire. Par contre, le temps d'accès augmente comparé à la solution précédente. On n'accède pas aux tags du L4 en parallèle du L3 : à la place, il faut consulter les tags du L3, détecter un défaut de cache L3, et ensuite accèder aux tags. ===Les caches RAM-configurables=== Un autre avantage des caches splittés est qu'on peut les modifier pour servir à la fois de mémoire cache, mais aussi de ''local store'', de mémoire RAM de petite taille. Le fonctionnement est assez simple à comprendre. Lors d'un accès au cache, on accède aux tags, puis à la RAM interne au cache. Lors d'un accès au ''local store'', on contourne l'accès au tags et on accède à la RAM interne au cache directement. Il s'agit de la technique du '''cache RAM-configurable''. L'usage de cache RAM-configurable est fréquent sur les cartes graphiques récentes, qui incorporent un ou plusieurs processeurs multicoeurs, dont le cache L1 de données est un cache RAM-configurable. [[File:Hydride cache - local store.png|centre|vignette|upright=2.0|Hydride cache - local store]] ===La compression de cache=== Une autre optimisation permise par les ''phased caches'' est l'implémentation de techniques de '''compression de cache''', qui visent à compresser des lignes de cache. L'intérêt est qu'on peut stocker plus de données dans le cache, à capacité égale. L'inconvénient est qu'on doit compresser/décompresser les lignes de cache, ce qui demande un circuit en plus et allonge les temps d'accès. En effet, le temps mis pour compresser/décompresser une ligne de cache s'ajoute au temps d'accès. Aussi, la compression de cache sert surtout pour les caches de bas niveau dans la hiérarchie mémoire, les gros caches aux temps d'accès assez longs. Une première technique, assez simple à implémenter et peu couteuse en circuit, est celle de la '''compression des lignes de cache nulles'''. Elle compresse uniquement les lignes de cache qui ne contiennent que des zéros. L'idée est qu'on ajoute, dans la mémoire des tags, un bit de contrôle pour chaque ligne de cache appelé le bit ''null''. Il indique si la ligne de cache ne contient que des zéros. Quand on lit une ligne de cache, la mémoire des tags est accédée et on vérifie le bit ''null'' : s'il vaut 1, on n'accède pas à la mémoire cache de données et un multiplexeur envoie un zéro sur le port de lecture. Le bit ''null'' est fixé lors de l'écriture d'une ligne de cache : elle passe dans un comparateur avec zéro relié à la mémoire des tags. La comparaison avec zéro peut se faire en parallèle de l'écriture ou avant (dans ce cas, on n'écrit pas la ligne de cache dans le cache). Les autres techniques de compression de cache permettent de compresser autre chose que des lignes de cache nulles. L'idée est qu'une ligne de cache physique peut par moment mémoriser plusieurs lignes de caches compressées. Par exemple, prenons un cache dont les lignes de cache font 64 octets. Il est possible de compresser deux lignes de cache pour qu'elles fassent chacune 32 octets, et les stocker dans une seule ligne de cache. Les deux lignes de cache auront des tags différents, mais pointeront sur la même ligne de cache physique. Et cela demande d'utiliser un ''phased cache'' dont la mémoire pour les tags est plus grande que la mémoire pour les données. Il n'y a donc plus une bijection entre tags et ligne de cache, mais une relation surjective. Chose qui n'est possible qu'avec un ''phased cache''. De plus, des bits de contrôles associés à chaque ''tag'' indiquent où se trouvent les lignes de cache compressées dans la ligne de cache : est-ce que c'est les 32 octets de poids fort ou de poids faible ? [[File:Compression de cache.png|centre|vignette|upright=2|Compression de cache]] Il ne semble pas que les techniques de compression de cache soient implémentées sur les processeurs modernes. Aucun n'utilise de compression de cache, à ma connaissance. Il faut dire que les techniques connues sont de mauvais compromis : le temps d'accès du cache augmente beaucoup, le cout en circuit pourrait être utilisé pour un cache non-compressé mais plus grand. Et notons que la compression de cache ne marche que si les données peuvent se compresser. Si ce n'est pas le cas, une partie de la mémoire des tags est inutilisée. Une revue de la littérature académique sur la compression de cache est disponible via ce lien, pour les curieux : * [https://inria.hal.science/hal-03285041 Understanding Cache Compression, par Carvalho et Seznec]. ==L'adressage physique ou logique des caches== Le cache utilise les adresses à lire/écrire pour déterminer s'il a une copie de la donnée en son sein. Mais l’interaction entre caches et mémoire virtuelle donne lieu à un petit problème : l'adresse utilisée est-elle une adresse virtuelle/logique ou physique ? La réponse varie suivant le processeur : certains caches utilisent l'adresse virtuelle, tandis que d'autres prennent l'adresse physique. On parle de cache '''virtuellement tagué''' dans le premier cas et de cache '''physiquement tagué''' dans le second. {| |[[File:Cache tagué virtuellement.png|vignette|Cache tagué virtuellement.]] |[[File:Cache tagué physiquement.png|vignette|Cache tagué physiquement.]] |} ===L'accès à un cache physiquement/virtuellement tagué=== La manière d'accéder à un cache dépend de s'il est virtuellement ou physiquement tagué. Il faut utiliser l'adresse virtuelle pour les premiers, physique pour les seconds. Avec un cache virtuellement tagué, l'adresse logique peut être envoyée directement au cache. La MMU ne traduit les adresses que s'il faut accéder à la mémoire RAM. Ces caches sont donc plus rapides. Avec un cache physiquement tagué, le processeur doit traduire l'adresse logique en adresse physique dans la MMU, avant d'accéder au cache. La traduction d'adresse se fait soit en accédant à une table des pages en mémoire RAM, soit en accédant à un cache spécifiquement dédié à accélérer la traduction d'adresse, la TLB (''Translation Lookaside Buffer''). Dans la quasi-totalité des cas, la traduction d'adresse passe par la TLB, ce qui fait qu'elle est raisonnablement rapide. Toujours est-il que chaque accès au cache demande d'accéder à la TLB et de faire la traduction d'adresse avant d'accéder au cache. L'accès est donc plus lent que sur les caches virtuellement tagués, où les accès sont plus directs. [[File:Virtual and Physical addressing.svg|centre|vignette|upright=2|Cache tagué virtuellement versus physiquement tagué.]] ===Les défauts des caches virtuellement tagués=== Les caches physiquement tagués sont moins rapides que les caches virtuellement adressés. Pourtant, les caches virtuellement tagués sont peu fréquents sur les processeurs modernes. Et la raison est assez intéressante : c'est une question d'adresses homonymes et synonymes. ====Les droits d'accès doivent être vérifiés lors d'un accès au cache==== Un premier problème est que la protection mémoire est compliquée avec de tels caches. Rappelons que certaines portions de mémoire sont accessibles seulement en lecture, ou sont interdites en écriture, sont inexécutables, etc. Ces droits d'accès sont gérés par la MMU, qui vérifie pour chaque accès mémoire que l'accès est autorisé. En bypassant la MMU, l'accès au cache virtuellement tagué ne permet pas de faire ces vérifications. Il est possible de charger une donnée en lecture seule dans le cache, mais d'y faire des accès en écriture pour les accès ultérieurs. Les solutions à cela sont multiples. La première consiste à consulter la MMU en parallèle de l'accès au cache. L'accès au cache est alors réalisé de manière spéculative, et est ensuite confirmé/annulé une fois que la MMU a rendu son verdict. Les performances du cache restent alors les mêmes : l'accès à la MMU se fait en parallèle de l'accès au cache, pas avant. Une autre solution est d'ajouter les droits d'accès en question dans la ligne de cache, dans les bits de contrôle situés après le Tag. Chaque accès au cache récupère ces bits de contrôle et vérifie si l'accès est autorisé. L'inconvénient est que les lignes de cache deviennent plus longues, les droits d'accès sont dupliqués entre MMU et cache. Mais si le budget en transistor suit, ce n'est rien d'insurmontable. ====Les adresses homonymes perturbent la gestion du cache==== Pour rappel, une adresse logique homonyme correspond à plusieurs adresses physiques différentes. Elles surviennent quand chaque programme a son propre espace d'adressage. Dans ce cas, une adresse logique correspondra à une adresse physique différente par programme.Une autre manière de voir les choses est qu'il y a en réalité deux adresses homonymes, qui ont la même valeur, mais appartiennent à des espaces d'adressage différentes. Et c'est cette seconde interprétation que nous allons utiliser. Les caches doivent gérer ces adresses homonymes et faire en sorte que la lecture/écriture d'une adresse homonyme se fasse à la bonne adresse physique, dans la bonne ligne de cache. Et autant un cache physiquement tagué n'a aucun problème avec ça, vu qu'il ne gère que des adresses physiques, autant des problèmes surviennent avec les caches virtuellement tagués. Le problème est que les caches virtuellement tagués doivent faire la différence entre deux adresses homonymes de même valeur. Pour corriger ces problèmes, il existe deux grandes méthodes. La première méthode est simple : '''vider les caches''' en changeant de programme. Leur contenu est rapatrié en mémoire RAM, puis les caches sont remis à zéro. Le vidage du cache recopie les lignes de cache ''dirty'' (modifiées) en RAM, puis efface/invalide tout le cache. C'est à cela que servent les instructions ''clean'' et d'invalidation vues plus haut, elles ont été inventées pour cette situation précise. Lorsque le système d'exploitation déclenche une commutation de contexte, à savoir qu'il change le programme en cours d'exécution, le processeur vide tous les caches du processeur. Les interruptions font la même chose, elles vide tous les caches du processeur. Une seconde méthode numérote chaque programme en cours d'exécution, chaque processus. Le numéro attribué est spécifique à chaque processus, ce qui fait qu'il est appelé un '''identifiant de processus CPU'''. Le processeur mémorise l'identifiant du programme en cours d'exécution dans un registre dédié. L'identifiant de processus CPU est utilisé lors des accès mémoire. Chaque ligne de cache contient le numéro de l'espace d'adressage associé, dans son ''tag''. Lors de chaque accès mémoire, l'ID du registre est comparé à l'ID de la ligne de cache accédée, pour vérifier que l'accès mémoire accède à la bonne donnée. Cette méthode n'est pas très économe en termes de transistors. L'usage d'identifiant de processus CPU est clairement meilleure en termes de performance, les commutations de contexte sont plus rapides. Par contre, le budget en transistor est plus important. Un autre défaut de cette méthode est que l'identifiant de processus est généralement codé sur une dizaine de bits, alors que le système d'exploitation utilise des identifiants de processus beaucoup plus larges, de 32 à 64 bits sur les CPU 32/64 bits. L'OS doit gérer la correspondance entre identifiants de processus CPU et ceux de l'OS. Parfois, pour cette raison, les OS n'utilisent pas toujours ce système d'identifiant de processus CPU. ====Les adresses synonymes perturbent aussi la gestion du cache==== La gestion des adresses synonymes est aussi un gros problème sur les caches virtuellement tagués. Pour rappel, il s'agit du cas où des adresses logiques différentes pointent vers la même adresse physique. Typiquement, quand deux programmes se partagent un morceau de mémoire, ce morceau correspondra à des adresses synonymes dans les deux espaces d'adressage. Mais il arrive que l'on ait des adresses synonymes dans le même espace d'adressage, ce n'est pas si rare ! Autant les adresses synonymes ne posent aucun problème avec les caches physiquement tagués, ce n'est pas le cas avec les caches virtuellement adressés. Sur ces caches, deux adresses logiques synonymes vont tomber dans deux lignes de cache différentes. Corriger ce problème demande d'ajouter des circuits annexes pour détecter les adresses synonymes, qui sont vraiment complexes et ont un cout en termes de performance. Aussi, les caches virtuellement tagués sont très peu utilisés sur les processeurs modernes. ===Les caches virtuellement adressés, mais physiquement tagués=== Si les caches physiquement et virtuellement tagués ont des défauts, il existe un intermédiaire qui est un bon compromis entre ces deux extrêmes. Il s'agit des '''caches virtuellement adressés - physiquement tagués''', aussi appelés '''caches pseudo-virtuels'''. Pour comprendre comment ils fonctionnent, précisons que ces caches sont soit des caches ''direct-mapped'', soit des caches associatifs par voie (composés de plusieurs RAM ''direct-mapped'' accédées en parallèle, plusieurs voies). L'accès à ce genre de cache se fait en deux temps : on accède à un ou plusieurs RAM ''direct-mapped'' et on vérifie ensuite les ''Tags'' pour sélectionner la bonne voie. Sur les caches ''direct-mapped'', on n'a qu'une seule RAM ''direct-mapped''. Sur les caches associatifs, on a plusieurs RAM ''direct-mapped'', appelées des voies, qui sont accédées en parallèle. L'accès se fait donc en deux étapes : adresser les RAM ''direct-mapped'' avec un indice, vérifier les ''tags'' avec le reste de l'adresse. Une autre chose à rappeler est que l'adresse logique est composée de deux parties : un numéro de page logique qui indique dans quel page se situe l'adresse, un décalage/''offset'' qui indique la position de l'adresse dans la page. La traduction d'adresse transforme le numéro de page logique en numéro de page physique, mais laisse le décalage intouché. L'idée est d'utiliser le décalage pour adresser les RAM avec le décalage, tandis que le numéro de page sert de ''tag''. Le décalage est découpé en deux lors de l'accès au cache : les bits de poids fort forment l'indice (l'adresse envoyée à la voie), les bits de poids faible donnent la position de l'adresse dans la ligne de cache. L'idée est d'utiliser un numéro de page physique pour les ''tags'', mais d'adresser les voies avec le décalage logique. Les deux servent à des instants différents : vérification des ''tags'' pour l'adresse physique, accès aux voies pour l'adresse logique. Ainsi, le problème des adresses synonymes ou homonymes est résolu par l'utilisation de l'adresse physique pour les tags. Par contre, l'accès au cache est plus rapide, car on utilise l'adresse logique pour la première étape. Le processeur accède à la TLB et récupère l'adresse physique pendant que l'on adresse les voies, les deux sont faits en parallèle, ce qui fait que tout se passe comme si l'accès à la TLB était gratuit. La TLB étant assez rapide comparé au cache, l'adresse physique est disponible quand on doit faire la comparaison avec les ''tags''. [[File:Virtual - Physical - Pseudo Virtual addressing.svg|centre|vignette|upright=2|Adressage pseudo virtuel des caches.]] Il s'agit d'un excellent compromis entre performance et correction des problèmes des adresses synonymes/homonymes. Tous les caches des processeurs haute performance utilisent cette méthode, au moins pour leurs caches L1. Les caches L2 tendent à utiliser des caches physiquement adressés, pour lesquels la latence d'accès est suffisante pour qu'on accède à la TLB en amont. La raison est assez simple à expliquer, elle provient d'une contrainte assez précise sur le calcul de l'indice. La conséquence est qu'un cache ''direct-mapped'' ne peut pas dépasser la taille d'une page, soit 4 kibioctets sur les ordinateurs actuels. Sur les caches associatifs, on peut dépasser cette limite en augmentant le nombre de voies, mais la taille maximale d'une voie reste celle d'une page. Cette contrainte n'est pas trop grave sur les caches de petite taille, dont les caches L1. La plupart d'entre eux ont trouvé un compromis idéal avec moins d'une dizaine de voies par cache, chacun de 4 kibioctets, ce qui donne des caches allant de 16 à 64 kibioctets, soit entre 4 et 16 voies. Par contre, un cache de grande taille doit utiliser un grand nombre de voies, ce qui est peu pratique. Aussi, cette technique de caches pseudo-virtuels n'est pas toujours appliquée sur les caches L2, qui sont physiquement adressés. Il faut dire qu'on accède au cache L2 lors d'un défaut dans le cache L1, et l'adresse physique est disponible à ce moment-là, elle a déjà été récupérée lors de l'accès au cache L1. On peut donc l'utiliser pour adresser le cache L2 sans perte de performance. ==Le remplacement des lignes de cache== Lorsqu'un cache est rempli et qu'on charge une nouvelle donnée dedans, il faut faire de la place pour cette dernière. Dans le cas d'un cache directement adressé, il n'y a rien à faire vu que la ligne de cache à évincer est déterminée lors de la conception du cache. Mais pour les autres caches, la donnée peut aller dans n'importe quelle ligne ou voie. Or, le choix des données à rapatrier en RAM doit être le plus judicieux possible : on doit virer de préférence des données inutiles. Rapatrier une donnée qui sera surement utilisée sous peu est inutile, et il vaudrait mieux supprimer des données qui ne serviront plus ou alors dans longtemps. Il existe différents algorithmes spécialement dédiés à résoudre ce problème efficacement, directement câblés dans les unités de gestion du cache. Certains sont vraiment très complexes, aussi je vais vous présenter quelques algorithmes particulièrement simples. Mais avant de voir ces algorithmes, il faut absolument que je vous parle d'une chose très importante. Quel que soit l'algorithme en question, il choisit la ligne de cache à évincer et recopie son contenu dans la RAM. Ce qui demande d'identifier et de sélectionner une ligne de cache parmi toutes les autres. Pour cela, le circuit de remplacement attribue une adresse chaque ligne de cache ! Vous avez bien vu : chaque ligne de cache est numérotée par une adresse, interne au cache. ===Le remplacement aléatoire=== Premier algorithme : la donnée effacée du cache est choisie au hasard ! C'est contre-intuitif, mais cet algorithme donne des résultats assez honorables, en plus d'utiliser très peu de portes logiques (un générateur de nombres pseudo-aléatoire est un circuit assez simple). Généralement, les défauts de cache sont séparés par un nombre assez important et irrégulier de cycles d'horloge. Dans ces conditions, cette technique donne un bon résultat. ===FIFO : first in, first out=== Avec l'algorithme FIFO, la donnée effacée du cache est la plus ancienne, celle chargée dans le cache avant les autres. Cet algorithme est très simple à implémenter en circuit, concevoir une mémoire de type FIFO n'étant pas très compliqué, comme on l’a vu dans le chapitre dédié à ce type de mémoires. Et on peut dire que dans le cas d'un cache, l'implémentation est encore plus simple et se contente d'un seul registre/compteur. Typiquement, il suffit d'ajouter un registre qui mémorise où se situe la donnée la plus récente. Toute insertion d'une nouvelle donnée se fait à l'adresse suivante, ce qui demande juste d'incrémenter le registre avant d'utiliser son contenu pour l'accès mémoire. [[File:Algorithme FIFO de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme FIFO de remplacement des lignes de cache.]] Cet algorithme possède une petite particularité sur les caches associatifs par voie : en augmentant le nombre d'ensembles, les performances peuvent se dégrader : c'est ce qu'on appelle l''''anomalie de Bélády'''. ===MRU : most recently used=== Avec l'algorithme MRU, la donnée remplacée est celle qui a été utilisée le plus récemment. Cet algorithme s'implémente simplement avec un registre, dans lequel on place le numéro de la dernière ligne de cache utilisée. Cet algorithme de remplacement est très utile quand un programme traverse des tableaux du premier élément jusqu'au dernier : les données du tableau sont rarement réutilisées, rendant le cache inutile. Il est prouvé que dans ces conditions, l'algorithme MRU est optimal. Mais dans toutes les autres conditions, cet algorithme a des performances assez misérables. ===LFU : least frequently used=== Avec l'algorithme LFU, la donnée supprimée est celle qui est utilisée le moins fréquemment. Cet algorithme s'implémente en associant un compteur à chaque ligne de cache, qui est incrémenté à chaque accès mémoire. La ligne la moins récemment utilisée est celle dont le compteur associé a la plus petite valeur. Implémenter cet algorithme prend pas mal de transistors, car il faut rajouter autant de compteurs qu'il y a de lignes de cache, en plus d'un circuit pour comparer les compteurs et d'un encodeur. [[File:Algorithme LFU de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme LFU de remplacement des lignes de cache]] ===LRU : least recently used=== Avec l'algorithme LRU, la donnée remplacée est celle qui a été utilisée le moins récemment. Cet algorithme se base sur le principe de localité temporelle, qui stipule qu'une donnée accédée récemment a de fortes chances d'être réutilisée dans un futur proche. Et inversement, la donnée la moins récemment utilisée du cache est celle qui a le plus de chance de ne servir à rien dans le futur. Autant la supprimer en priorité pour faire de la place à des données potentiellement utiles. Implémenter l'algorithme LRU peut se faire de différentes manières, qui ont pour point commun d'enregistrer les accès au cache pour en déduire la ligne la moins récemment accédée. La manière la plus simple demande d'utiliser un compteur pour chaque ligne de mémoire cache, un peu comme le LFU. La différence avec le LFU est que le compteur n'est pas incrémenté lors d'un accès mémoire. À la place, ce compteur est incrémenté régulièrement, chaque incrémentation ayant lieu en même temps pour tous les compteurs. Quand un bloc est chargé dans le cache, ce compteur est mis à zéro. Quand une ligne de cache doit être remplacée, un circuit va vérifier la valeur de tous les compteurs : la ligne LRU (la moins récemment utilisée), est celle dont le compteur a la valeur la plus haute. Le circuit est composé d'un paquet de comparateurs, et d'un encodeur, comme pour l'agorithme LFU. ===Les approximations du LRU=== Implémenter le LRU demande un nombre de transistors proportionnel au carré du nombre de lignes de cache. Autant dire que le LRU devient impraticable sur de gros caches. Ce qui fait que les processeurs modernes implémentent des variantes du LRU, moins couteuses en transistors, qui donnent un résultat approximativement semblable au LRU. En clair, ils ne sélectionnent pas toujours la ligne de cache la moins récemment utilisée, mais une ligne de cache parmi les moins récemment utilisées. Ce n'est pas un problème si grave que cela car les lignes les moins récemment utilisées ont toutes assez peu de chance d'être utilisées dans le futur. Entre choisir de remplacer une ligne qui a 0,5 % de chances d'être utilisée dans le futur et une autre qui a une chance de seulement 1 %, la différence est négligeable en termes de taux de succès. Mais les gains en termes de circuits ou de temps d'accès au cache de ces algorithmes sont très intéressants. L'algorithme le plus simple consiste à couper le cache (ou chaque voie s'il est associatif) en plusieurs sections. L'algorithme détermine la section la moins récemment utilisée, avant de choisir aléatoirement une ligne de cache dans cette section. Pour implémenter cet algorithme, il nous suffit d'un registre qui mémorise le morceau le moins récemment utilisé, et d'un circuit qui choisit aléatoirement une ligne de cache. Cette technique s'adapte particulièrement bien avec des caches associatifs à voies : il suffit d'utiliser autant de morceaux que de voies. Autre algorithme, un peu plus efficace : le '''pseudo-LRU de type M'''. Cet algorithme attribue un bit à chaque ligne de cache, bit qui sert à indiquer de façon approximative si la ligne de cache associée est une candidate pour un remplacement ou non. Il vaut 1 si la ligne n'est pas une candidate pour un remplacement et zéro sinon. Le bit est mis à 1 lorsque la ligne de cache associée est lue ou écrite. Évidemment, au fil du temps, toutes les lignes du cache finiront par avoir leur bit à 1. Lorsque cela arrive, l'algorithme remet tous les bits à zéro, sauf pour la dernière ligne de cache accédée. L'idée derrière cet algorithme est d'encercler la ligne de cache la moins récemment utilisée au fur et à mesure des accès. L'encerclement commence lorsque l'on remet tous les bits associés aux lignes de cache à 0, sauf pour la ligne accédée en dernier. Au fur et à mesure des accès, l'étau se resserre autour de la ligne de cache la moins récemment utilisée. Après un nombre suffisant d'accès, l'algorithme donne une estimation particulièrement fiable. Et comme les remplacements de lignes de cache sont rares comparés aux accès aux lignes, cet algorithme finit par donner une bonne estimation avant qu'on ait besoin d'effectuer un remplacement. Le dernier algorithme d'approximation, le '''PLURt''', se base sur ce qu'on appelle un arbre de décision. Il a besoin de n − 1 bits pour déterminer la ligne LRU. Ces bits doivent être organisés en arbre, comme illustré plus bas. Chacun de ces bits sert à dire : le LRU est à ma droite ou à ma gauche : il est à gauche si je vaux 0, et à droite si je vaux 1. Trouver le LRU se fait en traversant cet arbre, et en interprétant les bits un par un. Au fur et à mesure des lectures, les bits sont mis à jour dans cet arbre, et pointent plus ou moins bien sur le LRU. La mise à jour des bits s'effectue lors des lectures et écritures : quand une ligne est lue ou écrite, elle n'est pas la ligne LRU. Pour l'indiquer, les bits à 1 qui pointent vers la ligne de cache sont mis à 0 lors de la lecture ou écriture. {| |[[File:Organisation des bits avec l'algorithme PLURt.jpg|vignette|Organisation des bits avec l'algorithme PLURt.]] |[[File:Ligne de cache pointée par les bits de l'algorithme.png|vignette|Ligne de cache pointée par les bits de l'algorithme.]] |} ===LRU amélioré=== L'algorithme LRU, ainsi que ses variantes approximatives, sont très efficaces tant que le programme respecte relativement bien la localité temporelle. Par contre, Le LRU se comporte assez mal dans les circonstances ou la localité temporelle est mauvaise mais où la localité spatiale est respectée, le cas le plus emblématique étant le parcours d'un tableau. Pour résoudre ce problème, des variantes du LRU existent. Une variante très connue, l''''algorithme 2Q''', utilise deux caches : un cache FIFO pour les données accédées une seule fois et un second cache LRU. Évidemment, les données lues une seconde fois sont migrées du cache FIFO vers le cache LRU, ce qui n'est pas très pratique. Les processeurs n'utilisent donc pas cette technique, mais celle-ci est utilisée dans les caches de disque dur. D'autres variantes du LRU combinent plusieurs algorithmes à la fois et vont choisir lequel de ces algorithmes est le plus adapté à la situation. Notre cache pourra ainsi détecter s’il vaut mieux utiliser du MRU, du LRU, ou du LFU suivant la situation. ==Les écritures dans le cache : gestion et optimisations== Les écritures se font à une adresse mémoire bien précise, qui peut ou non être chargée dans le cache. Si la donnée à écrire est chargée dans le cache, elle est modifiée directement dans le cache, mais elle ne l'est pas forcément en mémoire RAM. Suivant le processeur, les écritures sont ou non propagées en mémoire RAM. Il existe deux stratégies d'écritures, appelées respectivement le ''write-back'' et le ''write-through''. Avec un cache ''write-back'', si la donnée à mettre à jour est présente dans le cache, on écrit dans celui-ci sans écrire dans la mémoire RAM. Dans ces conditions, une donnée n'est enregistrée en mémoire que si celle-ci quitte le cache, ce qui évite de nombreuses écritures mémoires inutiles. [[File:Cache write-through.png|centre|vignette|upright=2|Cache write-through.]] Avec les caches '''Write-Through''', toute écriture dans le cache est propagée en RAM. Cette stratégie augmente le nombre d'écritures dans la mémoire RAM, ce qui peut saturer le bus reliant le processeur à la mémoire. Les performances de ces caches sont donc légèrement moins bonnes que pour les caches ''write back''. Par contre, ils sont utiles dans les architectures avec plusieurs processeurs, comme nous le verrons dans les chapitres sur les architectures multiprocesseurs. [[File:Cache write-back.png|centre|vignette|upright=2|Cache write-back.]] ===Les caches ''Write-through''=== Sans optimisation particulière, on ne peut écrire dans un cache ''write-through'' pendant qu'une écriture en RAM a lieu en même temps : cela forcerait à effectuer deux écritures simultanées, en comptant celle imposée par l'écriture dans le cache. Pour éviter cela, certains caches ''write-through'' intègrent un '''tampon d’écriture''', qui sert de file d'attente pour les écritures en RAM. C'est une mémoire FIFO dans laquelle on place temporairement les données à écrire en RAM, où elles attendent en attendant que la RAM soit libre. Grâce à lui, le processeur peut écrire dans un cache même si d'autres écritures sont en attente dans le tampon d'écriture. Par souci d'efficacité, des écritures à la même adresse en attente dans le tampon d’écriture sont fusionnées en une seule. Cela fait un peu de place dans le tampon d’écriture, et lui permet d'accumuler plus d'écritures avant de devoir bloquer le cache. Il est aussi possible de fusionner des écritures à adresses consécutives de la mémoire en une seule écriture en rafales. Dans les deux cas, on parle de '''combinaison d'écriture'''. Mais la technique du tampon d'écriture a cependant un léger défaut qui se manifeste dans une situation bien précise : quand le processeur veut lire une donnée en attente dans le tampon d’écriture. La première manière de gérer cette situation est de mettre en attente la lecture tant que la donnée n'a pas été écrite en mémoire RAM. On peut aussi lire la donnée directement dans le tampon d'écriture, cette optimisation portant le nom de '''''store-to-load forwading'''''. Dans tous les cas, il faut détecter le cas où une lecture accède à une donnée dans le tampon d'écriture. À chaque lecture, l'adresse à lire est envoyée au tampon d'écriture, qui vérifie si une écriture en attente se fait à cette adresse. Pour cela, le tampon d’écriture doit être un cache, dont chaque entrée mémorise une écriture. Chaque ligne de cache contient la donnée à écrire, et le tag de la ligne de cache contient l'adresse où écrire la donnée. Notons que cache d'écriture a une politique de remplacement de type FIFO, le tampon d'écriture non-optimisé étant une mémoire FIFO. ===Les caches ''Write-back''=== Les caches ''write-back'' ont beau avoir des performances supérieures à celles des caches ''write-through'', il existe des optimisations qui permettent d'améliorer leurs performances. Ces optimisations consistent à ajouter des caches spécialisés à côté du cache proprement dit. Ces caches permettent de mémoriser des données qui sont éliminées du cache par les algorithmes de remplacement de ligne cache, sans pour autant faire une écriture en RAM. En suivant la procédure habituelle de remplacement des lignes de cache, on doit rapatrier la ligne en RAM avant d'en charger une nouvelle. On peut améliorer la situation en faisant l'inverse : on charge la nouvelle ligne pendant que l'ancienne donnée est rapatriée en RAM. Ainsi, la nouvelle ligne est disponible plus tôt pour le processeur, diminuant son temps d'attente. Pour implémenter cette technique, on doit mémoriser l'ancienne ligne de cache temporairement dans un '''cache d’éviction''' (ou ''write-back buffer''). [[File:Cache d’éviction.png|centre|vignette|upright=2|Cache d’éviction]] Les caches directement adressés ou associatifs par voie possèdent aussi un tampon d’écriture amélioré. Pour limiter les défauts par conflit de ces caches, des scientifiques ont eu l'idée d'insérer un cache pour stocker les données virées du cache. En faisant ainsi, si une donnée est virée du cache, on peut alors la retrouver dans ce cache spécialisé. Ce cache s'appelle le '''cache de victime'''. Ce cache de victime est géré par un algorithme de suppression des lignes de cache de type FIFO. Petit détail : ce cache utilise un tag légèrement plus long que celui du cache directement adressé au-dessus de lui. L'index de la ligne de cache doit en effet être contenu dans le tag du cache de victime, pour bien distinguer deux adresses différentes, qui iraient dans la même ligne du cache juste au-dessus. [[File:Victim Cache Implementation Example.svg|centre|vignette|upright=1|Cache de victime.]] ===La configuration du fonctionnement du cache=== Sur de nombreux processeurs, il est possible de configurer la mémoire cache pour qu'elle fonctionne soit en mode ''write-back'', soit en mode ''write-through''. Pour cela, les processeurs modernes incorporent des '''registres de configuration du cache'''. Le terme ''registre de configuration du cache'' est assez transparent et indique bien quel est leur rôle. Ils configurent comment le cache est utilisé et permettent notamment de configurer le cache pour dire s'il doit fonctionner en mode ''write-back'' ou ''write-through''. Ils permettent aussi d'activer ou de désactiver la combinaison sur écriture. Les registres en question sont configurés soit par le BIOS, soit par le système d'exploitation. Ce sont des registres protégés, que les applications ne peuvent pas configurer, elles n'en ont pas le droit. Typiquement, ils ne sont accessibles en écriture qu'en mode noyau. Sur les processeurs x86, les registres de configuration du cache sont appelés des '''''Memory type range registers''''' (''MTRRs''). Les MTRRs sont assez nombreux, et il y a notamment une différence entre mode réel et protégé. Si vous vous souvenez des chapitres sur le mode d'adressage et la mémoire virtuelle, vous vous souvenez que les processeurs x86 incorporent plusieurs modes de fonctionnement. En mode réel, le processeur ne peut adresser qu'un mébioctet de RAM, avec un système de segmentation particulier. En mode protégé, le processeur peut adresser toute la mémoire et la segmentation fonctionne différemment, quand elle n'est pas simplement désactivée. Les MTRRs sont séparés en deux : ceux pour le mode réel, ceux pour le mode protégé. Les MTRRs fixes sont ceux qui configurent le cache en mode réel, ils étaient utilisés pour gérer l'accès au BIOS, à la mémoire VGA de la carte graphique, et quelques autres accès aux entrées-sorties basiques gérées nativement par le BIOS. Pour le mode protégé, les processeurs au-delà du 386 incorporent des MTRRs variables, qui servent pour les autres entrées-sorties en général, notamment les périphériques PCI, la mémoire vidéo de la carte graphique, et j'en passe. De nos jours, les registres de configuration du cache sont désuets et cette fonctionnalité est gérée directement par la mémoire virtuelle. La table des pages contient, pour chaque page mémoire, des bits de contrôle qui disent si la page mémoire est cacheable ou non. Le contournement de cache est alors géré par le système de mémoire virtuelle, le cache de TLB et tout ce qui va avec. ===L’allocation sur écriture=== Que faire quand une écriture modifie une donnée qui n'est pas dans le cache ? Doit-on écrire la donnée dans le cache, ou non ? Si la donnée est écrite dans le cache, on dit que le cache fait une '''allocation sur l'écriture''' (ou ''write-allocate''). Certains caches effectuent une telle allocation sur écriture, mais d'autres ne le font pas ou du moins pas systématiquement. L’allocation sur écriture peut se décliner en deux sous-catégories : le '''chargement à la demande''' et l''''écriture immédiate'''. Dans le premier cas, on charge la donnée à modifier dans le cache, et on la remplace avec la donnée écrite. Dans l'écriture immédiate, l'écriture a lieu directement dans le cache et la donnée à modifier n'est pas chargée dans le cache. Évidemment, seule une portion de la ligne de cache contient la donnée écrite (valide), et le reste contient des données invalides. Le cache doit savoir quelles sont les portions du cache qui sont valides : cela demande d'utiliser un ''sector cache''. [[File:Write-back with write-allocation.svg|centre|vignette|upright=2|Cache Write-back avec allocation sur écriture.]] Sans allocation sur écriture, l'écriture est transférée directement aux niveaux de cache inférieurs ou à la mémoire si la donnée à modifier n'est pas dans le cache. Certains caches de ce genre utilisent une petite optimisation : lors de toute écriture, ils supposent que l'écriture donnera un succès de cache. Si c'est le cas, la ligne de cache qui contient la donnée est mise à jour avec la donnée à écrire. Mais si ce n'est pas le cas, la ligne de cache est invalidée, et l'écriture est transférée directement à la mémoire ou aux niveaux de cache inférieurs. [[File:Write-through with no-write-allocation.svg|centre|vignette|upright=2|Cache Write-through sans allocation sur écriture.]] ===La cohérence des caches=== Il arrive parfois que la mémoire d'un ordinateur soit mise à jour, sans que les modifications soient répercutées dans les mémoires cache. Dans ce cas, le cache contient une donnée périmée. Or, un processeur doit toujours éviter de se retrouver avec une donnée périmée et doit toujours avoir la valeur correcte dans ses caches : cela s'appelle la '''cohérence des caches'''. Il est possible de se retrouver avec des valeurs périmées dans le cache sur les ordinateurs avec plusieurs processeurs, ou si un périphérique écrit en RAM, les modifications ne sont pas répercutées automatiquement dans les mémoires cache. Pour résoudre ce problème, on peut interdire de charger dans le cache des données stockées dans les zones de la mémoire dédiées aux périphériques. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires cache. Autre solution : utiliser le fait que les périphériques déclenchent une interruption matérielle pour laisser le contrôleur DMA accéder à la mémoire. Dans ce cas, il suffit de vider les caches à chaque interruption matérielle. Le processeur peut le faire automatiquement, ou fournir des instructions pour. ==Le ''cache bypassing'' : contourner le cache== Dans certaines situations, le cache n'est pas utilisé pour certains accès mémoire. Diverses techniques permettent en effet d'effectuer des accès mémoire qui contournent le cache, qui ne passent pas par le cache. Ils sont utilisés quand l'accès en cache fait que des instructions normales ne fonctionnent pas. Par exemple, de tels accès directs à la RAM sont notamment utilisés pour l'implémentation d'instructions atomiques, une classe d'instructions spécifiques utilisées sur les processeurs multicœurs, dont nous parlerons dans plusieurs chapitres. Mais ils sont aussi utilisés pour l'accès aux périphériques, ce que nous allons voir maintenant. ===Accéder aux périphériques demande de contourner le cache=== Pour rappel, un périphérique (au sens d'entrée-sortie) contient des registres d’interfaçage qui ont une adresse au même titre que les cases mémoire. Un périphérique peut à tout instant modifier ses registres d’interfaçage, ce qui se répercute automatiquement dans l'espace d'adressage, mais rien de tout cela n'est transmis au cache. Si les accès aux périphériques passaient par l'intermédiaire du cache, on aurait droit à des problèmes. On aurait encore une fois droit à des problèmes de cohérence des caches. Le problème est géré différemment suivant que l'on utilise un espace d'adressage séparé ou des entrées-sorties mappées en mémoire. La solution est que les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache. Cela demande d'adapter le cache et le processeur. L'implémentation exacte dépend de comment sont adressés les périphériques. Pour rappel, il y a deux solutions pour adresser les périphériques : soit les périphériques disposent d'un espace d'adressage séparé de celui de la mémoire, soit il y un espace d'adressage unique partagé entre processeur et mémoire. Les deux cas donnent des solutions différentes. Avec un espace d'adressage séparé, l'espace d'adressage des périphériques n'est pas caché : aucun accès dans cet espace d'adressage ne passe par le cache. La mémoire cache n'est utilisée que pour l'espace d'adressage des mémoires, rien d'autre. C'est de loin le cas le plus simple : il suffit de concevoir le processeur pour. Il dispose d'instructions séparées pour les accès aux registres d’interfaçage et à la RAM/ROM, les premières ne passent pas par le cache, les autres si. Avec des entrées-sorties mappées en mémoire, la même solution est utilisée, mais dans une version un peu différente. Là encore, les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache, si on veut qu'ils marchent comme ils le doivent. Cela demande d'adapter le cache et le matériel pour que accès aux périphériques mappés en mémoire contournent le cache. Des adresses, voire des zones entières de la mémoire, sont marquées comme étant non-cachables. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires caches. Là encore, le processeur doit être prévu pour : on doit pouvoir le configurer de manière à marquer certaines zones de la RAM comme non-cacheable. Reste qu'il faut marquer des régions de la RAM comme non-cacheable. Pour cela, on améliore les registres de configuration du cache, vus plus haut, afin qu'ils permettent de configurer certaines portions de la RAM pour préciser qu'elles ne doivent pas être mises en cache, qu'il faut activer le contournement de cache pour celles-ci. ===Contourner le cache pour des raisons de performance=== Il arrive que des données avec une faible localité soient chargées dans le cache inutilement. Or, il vaut mieux que ces données transitent directement entre le processeur et la mémoire, sans passer par l'intermédiaire du cache. Pour cela, le processeur peut fournir des instructions d'accès mémoire qui ne passent pas par le cache, à côté d'instructions normales. De telle instructions sont appelées des '''instructions mémoire non-temporelles'''. Non-temporelle, dans le sens : pas de localité temporelle (c.a.d que les données ne seront pas réutilisées plus tard). Mais il existe aussi des techniques matérielles, où le cache détecte à l'exécution les lectures qui gagnent à contourner le cache. La dernière méthode demande d'identifier les instructions à l'origine des défauts de cache, le processeur accédant directement à la RAM quand une telle instruction est détectée. Si une instruction d'accès mémoire fait trop de défauts de cache, c'est signe qu'elle gagne à contourner le cache. L'idée est de mémoriser, pour chaque instruction d'accès mémoire, un historique de ses défauts de cache. Il existe plusieurs méthodes pour cela, mais toutes demandent d'ajouter de quoi mémoriser l'historique des défauts de cache des instructions. L'historique est mémorisé dans une mémoire appelée la '''table d’historique des défauts de lecture''' (''load miss history table''), qui est souvent un cache. L'historique en question est, dans sa version la plus simple, un compteur de quelques bits incrémenté à chaque succès de cache et décrémenté à chaque défaut de cache, qui indique si l'instruction a en moyenne fait plus de défauts ou de succès de cache. La table associe le ''program counter'' d'une instruction mémoire à cet historique. À la première exécution d'une instruction d'accès mémoire, une entrée de cette table est réservée pour l'instruction. Lors des accès ultérieurs, le processeur récupérer les informations associées et décide s'il faut contourner le cache ou non. ==La hiérarchie mémoire des caches== [[File:Cache Hierarchy.png|vignette|Hiérarchie de caches]] On pourrait croire qu'un seul cache est largement suffisant pour compenser la lenteur de la mémoire. Hélas, les processeurs sont devenus tellement rapides que les caches sont eux-mêmes très lents ! Pour rappel, plus une mémoire peut contenir de données, plus elle est lente. Et les caches ne sont pas épargnés. Si on devait utiliser un seul cache, celui-ci serait très gros et donc trop lent. La situation qu'on cherche à éviter avec la mémoire RAM revient de plus belle. Même problème, même solution : si on a décidé de diviser la mémoire principale en plusieurs mémoires de taille et de vitesse différentes, on peut bien faire la même chose avec la mémoire cache. Depuis environ une vingtaine d'années, un processeur contient plusieurs caches de capacités très différentes : les caches L1, L2 et parfois un cache L3. Certains de ces caches sont petits, mais très rapides : c'est ceux auxquels on va accéder en priorité. Viennent ensuite d'autres caches, de taille variable, mais plus lents. Les processeurs ont donc une hiérarchie de caches qui se fait de plus en plus complexe avec le temps. Cette hiérarchie est composée de plusieurs niveaux de cache, qui vont des niveaux inférieurs proches de la mémoire RAM à des niveaux supérieurs proches du processeur. Plus on monte vers les niveaux supérieurs, plus les caches sont petits et rapides. Un accès mémoire dans une hiérarchie de cache fonctionne comme suit : on commence par vérifier si la donnée recherchée est dans le cache le plus rapide, à savoir le cache L1. Si c'est le cas,n on la charge depuis ce cache directement. Si elle n’y est pas, on vérifie si elle est dans le cache de niveau supérieur, le cache L2. Et rebelote ! Si elle n'y est pas, on vérifie le cache du niveau supérieur. Et on répète cette opération, jusqu’à avoir vérifié tous les caches. Si la donnée n'est dans aucun cache, on doit alors aller chercher la donnée en mémoire. [[File:Hiérarchie de caches.png|centre|vignette|upright=2|Hiérarchie de caches]] Il y a des différences assez notables entre chaque niveau de cache. Par exemple, les différents niveaux de cache n'ont pas forcément les mêmes politiques de remplacement des lignes de cache. Le cache L1 a généralement une politique de remplacement simple, très rapide, mais peu efficace. De même, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Le cache le plus proche de la mémoire est appelé le '''cache de dernier niveau''', ''Last Level Cache'' en anglais. Il a parfois des caractéristiques totalement différentes des autres caches. Par exemple, sur les processeurs multicoeurs, le cache L3 n'est techniquement pas dans le cœur, mais fait partie d'un ensemble de circuits reliés, comme le contrôleur mémoire ou l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc. ===Les caches exclusifs et inclusifs=== Notons que du point de vue de cette vérification, il faut distinguer les caches inclusifs et exclusifs. Avec les caches inclusifs, si une donnée est présente dans un cache, alors elle est présente dans les caches des niveaux inférieurs, ce qui implique l'existence de données en doublon dans plusieurs niveaux de cache. À l'opposé, les caches exclusifs font que toute donnée est présente dans un seul cache, pas les autres. Il existe aussi des caches qui ne sont ni inclusifs, ni exclusifs. Sur ces caches, chaque niveau de cache gère lui-même ses données, sans se préoccuper du contenu des autres caches. Pas besoin de mettre à jour les niveaux de cache antérieurs en cas de mise à jour de son contenu, ou en cas d'éviction d'une ligne de cache. La conception de tels caches est bien plus simple. Dans les '''caches exclusifs''', le contenu d'un cache n'est pas recopié dans le cache de niveau inférieur. Il n'y a pas de donnée en double et on utilise 100 % de la capacité du cache, ce qui améliore le taux de succès. Par contre, le temps d'accès est un peu plus long. La raison est que si une donnée n'est pas dans le cache L1, on doit vérifier l'intégralité du cache L2, puis du cache L3. De plus, assurer qu'une donnée n'est présente que dans un seul cache nécessite aux différents niveaux de caches de communiquer entre eux pour garantir que l'on a pas de copies en trop d'une ligne de cache, ce qui peut prendre du temps. [[File:Caches exclusifs.png|centre|vignette|upright=2|Caches exclusifs]] Dans le cas des '''caches inclusifs''', le contenu d'un cache est recopié dans les caches de niveau inférieur. Par exemple, le cache L1 est recopié dans le cache L2 et éventuellement dans le cache L3. Ce genre de cache a un avantage : le temps d'accès à une donnée est plus faible. La raison est qu'il ne faut pas vérifier tout un cache, mais seulement la partie qui ne contient pas de donnée en doublon. Par exemple, si la donnée voulue n'est pas dans le cache L1, on n'est pas obligé de vérifier la partie du cache L2 qui contient la copie du L1. Ainsi, pas besoin de vérifier certaines portions du cache, ce qui est plus rapide et permet de simplifier les circuits de vérification. En contrepartie, l'inclusion fait que qu'une partie du cache contient des copies inutiles, comme si le cache était plus petit. De plus, maintenir l'inclusion est compliqué et demande des circuits en plus et/ou des échanges de données entre caches. [[File:Caches inclusifs.png|centre|vignette|upright=2|Caches inclusifs]] Maintenir l'inclusion demande de respecter des contraintes assez fortes, ce qui ne se fait pas facilement. Premièrement, toute donnée chargée dans un cache doit aussi l'être dans les caches de niveau inférieur. Ensuite, quand une donnée est présente dans un cache, elle doit être maintenue dans les niveaux de cache inférieurs. De plus, toute donnée effacée d'un cache doit être effacée des niveaux de cache supérieurs : si une donnée quitte le cache L2, elle doit être effacée du L1. Ces trois contraintes posent des problèmes si chaque cache décide du remplacement des lignes de cache en utilisant un algorithme comme LRU, LFU, MRU, ou autre, qui utilise l'historique des accès. En effet, dans ce cas, le cache décide de remplacer les lignes de cache selon l'historique des accès, historique qui varie suivant chaque niveau de cache. Par exemple, une donnée rarement utilisée dans le L2 peut parfaitement être très fréquemment utilisée dans le L1 : la donnée sera alors remplacée dans le L2, mais sera maintenue dans le L1. On observe aussi des problèmes quand il existe plusieurs caches à un seul niveau : chaque cache peut remplacer les lignes de cache d'une manière indépendante des autres caches du même niveau, donnant lieu au même type de problème. Pour maintenir l'inclusion, les caches doivent se transmettre des informations qui permettent de maintenir l'inclusion. Par exemple, les caches de niveaux inférieurs doivent prévenir les niveaux de cache supérieurs quand ils remplacent une ligne de cache. De plus, toute mise à jour dans un cache doit être répercutée dans les niveaux de cache inférieurs et/ou supérieurs. On doit donc transférer des informations de mise à jour entre les différents niveaux de cache. Généralement, le contenu des caches d'instruction n'est pas inclus dans les caches de niveau inférieurs, afin d'éviter que les instructions et les données se marchent sur les pieds. Enfin, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Dans ce cas, l'inclusion est plus difficile à maintenir, pour des raisons assez techniques. ===Les caches eDRAM, sur la carte mère et autres=== D'ordinaire, les mémoires caches sont intégrées au processeur, à savoir que cache et CPU sont dans le même circuit imprimé. Les caches sont donc fabriqués avec de la SRAM, seule forme de mémoire qu'on peut implémenter dans un circuit intégré. Intégrer tous les caches dans le processeur est une solution et efficace. Mais certains processeurs ont procédé autrement. [[File:Cache-on-a-stick module.jpg|vignette|Cache-on-a-stick module]] Des processeurs assez anciens incorporaient un cache L1 dans le processeur, mais plaçaient un cache L2 sur la carte mère. Le cache était clippé sur un connecteur sur la carte mère, un peu comme le sont les barrettes de mémoire. On parlait alors de '''''Cache on a stick''''' (COAST). On aurait pu s'attendre à ce que de tels caches soient en DRAM, vu qu'ils sont placés sur des barrettes de RAM, mais la ressemblance avec la mémoire RAM principale s'arrête là. Le cache était fabriqué en mémoire SRAM, même s'il est en théorie possible de faire de tels caches avec de la DRAM. Les premiers processeurs avec un cache faisaient ainsi, au début des années 90. Il a été introduiot sur les processeurs Motorola, et a été utilisé sur les IBM PC et les Macintosh de l'époque. Les ordinateurs Macintosh utilisaient de tels caches, pour la pluaprt des modèles. Pour ce qui est des PC, les premiers processeurs x86 faisaient pareil, notamment les processeurs Intel. Le 486, le Pentium et le Pentium 2 utilisaient des ''Cache on a stick''. L'avantage est que cela permettait de mettre plus de cache, à une époque où les circuits étaient limités en transistors. De plus, cela permettait au consommateur de choisir quelle quantité de cache il voulait, selon ses finances. Il était possible de laisser le processeur fonctionner soit sans mémoire cache, soit avec un cache de 256 Kibioctets, de 512 Kibioctets, etc. Il était possible d'upgrader le cache si besoin. Pour les CPU Intel, le cache était connecté sur le bus système, au même titre que la mémoire RAM et les entrées-sorties. Il faut dire que les processeurs de l'époque utilisaient un bus système et n'avaient pas de bus mémoire dédié. Mais en théorie, rien n’empêche de connecter le cache sur un bus mémoire dédié. Toujours est-il que les lectures et écritures étaient propagées à la fois dans le cache et la RAM. Les écritures se faisaient dans les deux, systématiquement dans la RAM, mais aussi dans le cache en cas de succès de cache. Les lectures étaient servies soit par le cache en cas de succès de cache, soit par la RAM en cas de défaut de cache. Si le cache répondait en premier, la transaction sur le bus se terminait précocement et l'accès en RAM était abandonné. [[File:Intel486 Иерархия памяти.png|centre|vignette|upright=2.5|Intel486 : le cache était connecté sur le bus système.]] À l'inverse, certains processeurs possédaient un cache fabriqué en mémoire DRAM, et plus précisément avec de la mémoire eDRAM. Le cache n'était pas intégré dans le même circuit imprimé que le processeur, mais profitait d'une architecture en ''chiplet''. Pour rappel, cela veut dire que le processeur est en réalité composé de plusieurs circuits intégré séparés, mais interconnectés et soudés sur un même PCB carré. Avec un cache en eDRAM, le cache avait son propre circuit intégré, séparé du circuit intégré du processeur ou du circuit intégré pour le contrôleur mémoire/IO. Un exemple est celui du cache des processeurs Intel de microarchitecture Broadwell, vus dans ce chapitre dans la section sur les caches splittés. Les tags étaient intégrés dans le circuit intégré du processeur, mais les données étaient mémorisées dans une puce d'eDRAM séparée. La puce eDRAM correspondait en réalité à une DRAM adressable qui servait de DRAM pour les données et mémorisaient les voies du cache. ==Les caches adressés par somme et hashés== Les caches adressés par somme sont optimisés pour incorporer certains calculs d'adresse directement dans le cache lui-même. Pour rappel, certains modes d'adressage impliquent un calcul d'adresse, qui ajoute une constante à une adresse de base. Généralement, l'adresse de base est l'adresse d'un tableau ou d'une structure, et la constante ajoutée indique la position de la donnée dans le tableau/la structure. Les caches hashés et les caches adressés par somme permettent de faire l'addition directement dans la mémoire cache. Voyons d'abord les caches hashés, avant de passer aux caches adressés par somme. Sur les '''caches hashés''', l'addition est remplacée par une autre opération, par exemple des opérations bit à bit du style XOR, AND ou OR, etc. Seulement, utiliser des opérations bit à bit pose un problème : il arrive que deux couples Adresse/décalage donnent le même résultat. Par exemple, le couple Adresse/décalage 11101111/0001 donnera la même adresse que le couple 11110000/0000. Dit autrement, deux adresses censées être différentes (après application du décalage) sont en réalité attribuées à la même ligne de cache. Il est toutefois possible de gérer ces situations, mais cela demande des astuces de haute volée pour faire fonctionner la mémoire cache correctement. Sur les '''caches adressés par somme''', le décodeur est modifié pour se passer de l'addition. Pour comprendre comment, il faut rappeler qu'un décodeur normal est composé de comparateurs, qui vérifient si l'entrée est égale à une constante bien précise. Sur un cache ordinaire, l'addition est faite séparément du décodage des adresses par le cache, dans l'unité de calcul ou dans l'unité de génération d'adresse. [[File:Non sum adressed cache.png|centre|vignette|upright=2|Cache normal.]] Mais les caches adressés par somme modifient le décodeur, qui est alors composé de comparateurs qui testent si la somme adresse + décalage est égale à une constante. [[File:Cache adressé par somme.png|centre|vignette|upright=2|Cache adressé par somme.]] Chaque circuit du décodeur fait le test suivant, avec K une constante qui dépend du circuit : : <math>A + B = K</math> Ce qui est équivalent à faire le test suivant : : <math>A + B - K = 0</math> En complément à deux, on a <math>- K = \overline{K} + 1</math>. En injectant dans l'équation précédente, on a : : <math>A + B + \overline{K} + 1 = 0</math> En réorganisant les termes, on a : : <math>A + B + \overline{K} = - 1</math> Il suffit d'utiliser un additionneur ''carry-save'' pour faire l'addition des trois termes. Rappelons qu'un tel additionneur fournit deux résultats en sortie : une somme calculée sans propager les retenues et les retenues en question. Notons que les retenues sont à décaler d'un cran, vu qu'elles sont censées s'appliquer à la colonne suivante. En notant la somme S et les retenues R, on a: : <math>S + (R << 1) = - 1 </math>, le décalage d'un cran à gauche étant noté <math><< 1</math>. Ensuite, -1 est codé avec un nombre dont tous les bits sont à 1 en complément à un/deux. : <math>S + (R << 1) = 111 \cdots 111111</math> [[File:Sum + retenue add.png|centre|vignette|upright=2|Sum + retenue add]] Un simple raisonnement nous permet de savoir si le résultat est bien -1, sans faire l'addition <math>S + (R << 1)</math>. En effet, on ne peut obtenir -1 que si la somme est l'inverse des retenues : un 0 dans le premier nombre correspond à un 1 dans l'autre, et réciproquement. En clair, on doit avoir <math>\overline{S} = R << 1</math>. Pour vérifier cela, il suffit de faire un simple XOR entre la somme et les retenues décalées d'un cran. On a alors : : <math>S \oplus (R << 1) = 111 \cdots 111111</math> La comparaison avec -1 se fait avec une porte ET à plusieurs entrées. En effet, la porte donnera un 1 seulement si tous les bits d'entrée sont à 1, ce qui est ce qu'on veut tester. Au final, l'additionneur pour l'addition adresse + décalage est remplacé par un additionneur carry-save suivi d'une couche de portes XOR et d'un comparateur avec une constante, ce qui économise de circuits et améliore les performances. [[File:Final circuit of sum addressed cache.png|centre|vignette|upright=2|Cache adressé par somme.]] En prenant en compte que la constante K est justement une constante, certaines entrées de l'additionneur carry-save sont toujours à 0 ou à 1, ce qui permet quelques simplifications à grand coup d’algèbre de Boole. Chaque additionneur complet qui compose l’additionneur carry-save est remplacée par des demi-additionneurs (ou par un circuit similaire). Autant dire que l'on gagne tout de même un petit peu en rapidité, en supprimant une couche de portes logiques. Le circuit de décodage économise aussi des portes logiques, ce qui est appréciable. ==Les caches à accès uniforme et non-uniforme== Intuitivement, le temps d'accès au cache est le même pour toutes les lignes de cache. Il s'agit de cache appelés '''caches à accès uniforme''', sous-entendu à temps d'accès uniforme. Mais sur les caches de grande capacité, il arrive souvent que le temps de propagation des signaux varie fortement suivant la ligne de cache à lire. D'ordinaire, on se cale sur la ligne de cache la plus lente pour caler la fréquence d'horloge du cache, même si on pourrait faire mieux. Cependant, les '''caches à accès non uniforme''' ont une latence différente pour chaque ligne d'un même cache. Certaines lignes de cache sont plus rapides que d'autres. Niveau terminologie, nous allons parler de caches UCA et NUCA : ''Uniform Access Cache'' pour les caches à accès uniforme, ''Non-Uniform Access Cache'' pour les caches à accès non-uniforme. [[File:Caches UCA et NUCA.png|vignette|Caches UCA et NUCA.]] Les caches NUCA et UCA sont souvent composés de plusieurs banques séparées, typiquement une par voie. Sur les caches UCA, les banques sont interconnectées avec le processeur de manière à ce que toutes les interconnexions ont la même longueur pour toutes les banques. Typiquement, les banques sont organisées en carré, avec les interconnexions qui partent du centre, avec une disposition en H, illustrée ci-contre Mais avec les caches NUCA, ce n'est pas le cas. Les interconnexions sont simplifiées et ont des longueurs différentes. Les caches NUCA n'ont pas tous le même genre d'interconnexions, qui dépendent du cache NUCA. En général, les interconnexion forme un réseau avec des sortes de routeurs qui redirigent les données/commandes vers la bonne destination : cache ou processeur. Les banques plus proches du processeur sont accessibles plus rapidement que celles éloignées, même si la différence n'est pas énorme. Les caches NUCA sont généralement associatifs par voie. Les plus simples utilisent une banque par voie pour le cache, ce qui fait que certaines voies répondent plus vite que les autres. La détection des succès de cache est alors plus rapide si la donnée lue/écrite est dans une voie/banque rapide. En théorie, les défauts de cache demandent de vérifier toutes les banques, et se calent donc sur la pire latence. Mais divers caches se débrouillent pour que ce ne soit pas le cas, soit en vérifiant les banquyes unes par une, soit par un mécanisme de recherche plus complexe. Les caches NUCA sont surtout utilisés pour les caches L3 et L4, éventuellement les caches L2. Les caches L1 sont systématiquement des caches UCA, car la latence de l'accès au cache L1 est utilisée par le processeur pour décider quand lancer les instructions. Pour simplifier, le processeur peut démarrer en avance une instruction avant qu'une opérande soit lue dans le cache L1, de manière à ce que la donnée arrive en entrée de l'ALU pile en même temps que l'instruction. Une histoire d'exécution dans le désordre et d'émission anticipée des instructions qu'on détaillera dans une bonne dizaine de chapitres. Toujours est-il que tout est plus simple pour le processeur si le cache L1 a un temps d'accès fixe. Par contre, les caches L3 et L4 sont traités en attendant que les données arrivent, le processeur reprend l'exécution des instructions quand les caches L3 et L4 ont terminé de répondre, pas avant. Avec l'association une banque = une voie, la correspondance ligne de cache → bloc de mémoire qui est statique : on ne peut pas déplacer le contenu d'une ligne de cache dans une autre portion de mémoire plus rapide suivant les besoins. Mais la recherche académique a étudié le cas où la correspondance entre une ligne de cache et une banque varie à l’exécution. Pour nommer cette distinction, on parle de caches S-NUCA (''Static NUCA'') et D-NUCA (''Dynamic NUCA''). Intuitivement, on s'attend à ce que les caches D-NUCA soient plus performants que les caches S-NUCA. Les lignes de cache les plus utilisées peuvent migrer dans une banque rapide, alors que les lignes de cache moins utilisées vont dans une banque éloignée. Les lignes de cache se répartissent dans le cache dynamiquement dans les banques où elles sont le plus adaptées. Mais paradoxalement, le gain des caches D-NUCA est presque nul, voire insignifiant. La raison est que les caches D-NUCA doivent incorporer un système pour déterminer dans quelle banque se situe la donnée pour détecter les succès/défauts de cache, ainsi qu'un système pour migrer les données entre banques. Et ce système augmente le temps d'accès au cache, réduisant à néant l'intérêt d'un cache D-NUCA. Si on économise quelques microsecondes de temps d'accès en passant d'un cache UCA à un cache S-NUCA, ce n'est pas pour les perdre en passant à un D-NUCA. La majorité des caches D-NUCA sont donc en cours de recherche, mais ne sont pas utilisés en pratique. ==La tolérance aux erreurs des caches== Une mémoire cache reste avant tout une mémoire RAM, bien que ce soit de la SRAM. Elle n'est pas parfaite et est donc sujette à des erreurs, qui peuvent inverser un bit ou l'effacer. De telles erreurs sont liées à des rayons cosmiques très énergétiques, à des particules alpha produites par le packaging ou le métal deu circuit intégré, peu importe : l'essentiel est qu'ils inversent parfois un bit. Les mémoires modernes savent se protéger contre de telles erreurs, en utilisant trois moyens. ===Les mémoires caches ECC et à bit de parité=== Le premier moyen est l'usage de codes correcteurs d'erreurs, qui ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Les bits ajoutés dépendent de la donnée mémorisée dans le byte, et servent à détecter une erreur, éventuellement à la corriger. Le cas le plus simple ajoute un simple bit de parité pour chaque byte et se contente de détecter les erreurs dans les corriger. Les autres codes ECC permettent eux de corriger des erreurs, mais ils demandent d'ajouter au moins deux bits par byte, ce qui a un cout en circuit plus élevé. Un simple bit de parité permet de détecter qu'un bit a été inversé, mais ne permet pas de corriger l'erreur. En soi, ce n'est pas un problème. Si une erreur est détectée, on considère que la ligne de cache est invalide. Le cache gère la situation comme un défaut de cache et va chercher la donnée valide en mémoire RAM. Le cout en circuits est donc faible, mais les défauts de cache sont plus nombreux. Les codes ECC sont eux capables de corriger les erreurs, si elles ne modifient pas trop de bits d'un coup. Par contre, ils utilisent deux à trois bits par octet, ce qui a un cout en circuits loin d'être négligeable. Il y a donc un compromis entre défauts de cache et cout en circuits. La gestion de l'ECC est différente suivant le niveau de cache. Généralement, le cache L1 n'utilise pas l'ECC mais se contente d'un simple bit de parité pour éviter la corruption de ses données. Le cache étant petit, les corruptions de données sont assez rares, et les défauts de cache induits faibles. Il est plus important d'utiliser un code de détection d'erreur simple, rapide, qui ne ralentit pas le cache et n'augmente pas sa latence. Si une ligne de cache est corrompue, il a juste à aller lire la ligne depuis le cache L2, ou un niveau de cache inférieur. Du moins, c'est possible sur le cache en question est un cache inclusif et/ou ''write-through''. Par contre, le niveau de cache L2 et ceux en-dessous utilisent presque systématiquement une mémoire SRAM ECC. La raison principale étant que ce sont des caches assez gros, pour lesquels la probabilité d'une erreur est assez élevée. Plus une mémoire a de bits et prend de la place, plus il y a une chance élevée qu'un bit s'inverse. Et vu que les caches L2/L3/L4 sont par nature plus lents et plus gros, ils peuvent se permettre le cout en performance lié à l'ECC, idem pour le cout en circuit. Sans compter qu'en cas d'erreur, ils doivent aller lire la ligne de cache originelle en mémoire RAM, ce qui est très lent ! Mieux vaut corriger l'erreur sur place en utilisant l'ECC. ===L'usage du ''memory scrubbing'' sur les caches=== La plupart des erreurs ne changent qu'un seul bit dans un byte, mais le problème est que ces erreurs s'accumulent. Entre deux accès à une ligne de cache, il se peut que plusieurs erreurs se soient accumulées, ce qui dépasse les capacités de correction de l'ECC. Dans ce cas, il existe une solution appelée le ''memory scrubbing'', qui permet de résoudre le problème au prix d'un certain cout en performance. Pour rappel, l'idée est de vérifier les lignes de caches régulièrement, pour éviter que les erreurs s'accumulent. Par exemple, on peut vérifier chaque ligne de cache toutes les N millisecondes, et corriger une éventuelle erreur lors de cette vérification. En faisant des vérifications régulières, on garantir que les erreurs n'ont pas le temps de s'accumuler, sauf en cas de malchance avec des erreurs très proches dans le temps. Il ne s'agit pas d'un rafraichissement mémoire, car les SRAM ne s'effacent pas), mais ça a un effet similaire. Et évidemment, le ''memory scrubbing'' a un cout en performance. On peut faire une comparaison avec le rafraichissement mémoire : les rafraichissement réguliers réduisent les performances, car cela fait des accès en plus. Des accès qui sont de plus timés à des instants bien précis qui ne sont pas forcément les plus adéquats. Il est possible qu'un rafraichissement ait lieu en même temps qu'un accès mémoire et le rafraichissement a la priorité, ce qui réduit les performances. La même chose arrive avec les vérifications du ''memory scrubbing''. Malgré tout, la technique a été utilisée sur les caches de certains processeurs commerciaux, dont des processeurs AMD Athlon et Athlon 64. Elle est surtout utilisable sur les caches L2/L3, pour lesquels le cout du pseudo-rafraichissement est acceptable. ==Un exemple de cache : le cache d'instruction== La grande majorité des processeurs utilise deux caches L1 séparés : un '''cache d'instructions''' dédié aux instructions, et un autre pour les données. Une telle organisation permet de charger une instruction tout en lisant une donnée en même temps. Notons que seul le cache L1 est ainsi séparé entre cache de données et d'instructions. Le cache d’instruction se situe en théorie entre l'unité de chargement et l'unité de décodage. En effet, ce cache prend en entrée une adresse et fournit une instruction. L'adresse est fournie par le ''program counter'', l'instruction est envoyée dans l'unité de décodage. Le cache se situe donc entre les deux. Le cache de données L1 est connecté au chemin de données, et notamment aux unités de communication avec la mémoire, pas au séquenceur. [[File:Caches L1 et positions dans le processeur.png|centre|vignette|upright=2.5|Caches L1 et positions dans le processeur]] Les deux caches sont reliés au processeur par des bus séparés, l'ensemble ressemble à une architecture Harvard, mais où les caches remplacent les mémoires RAM/ROM. Le cache d'instruction prend la place de la mémoire ROM et le cache de données prend la place de la mémoire RAM. Évidemment, il y a des niveaux de caches en dessous des caches de données/instruction, et ceux-ci contiennent à la fois données et instructions, les deux ne sont pas séparées dans des mémoires/caches séparés. Raison pour laquelle l'ensemble est appelé une '''architecture Harvard modifiée'''. Architecture Harvard, car l'accès aux données et instructions se font par des voies séparées pour le processeur, modifiée car la séparation n'est effective que pour le cache L1 et pas les autres niveaux de cache, et encore moins la RAM. Sur les processeurs modernes, il arrive très souvent que le processeur doive charger une instruction et lire/écrire une donnée en même temps. Et à vrai dire, c'est la règle plus que l'exception. L'usage d'une architecture Harvard modifiée permet cela très facilement : on peut accéder au cache d'instruction via un bus, et au cache de donnée avec l'autre ===Pourquoi scinder le cache L1 en cache d'instruction et de données=== L'usage d'un cache d’instruction séparé du cache de données est à contraster avec l'usage d'un cache L1 multiport unique, capable de mémoriser à la fois instructions et données. Les deux solutions sont possibles ont été utilisées. Les premiers processeurs avaient un cache L1 unique et multiport, mais ce n'est plus le cas sur les processeurs modernes, car les contraintes ne sont pas les mêmes. Le compromis à faire est celui entre deux petits caches rapides et un gros cache plus lent. Pour rappel, plus un cache est petit, plus il est rapide et chauffe moins. Donc au lieu d'utiliser, par exemple, un gros cache lent de 64 Kibioctets, on utilise deux caches de 32 kibioctets, plus rapides. La capacité totale est la même, mais le temps d'accès plus faible. En termes de temps d'accès, la meilleure solution est celle des deux caches simple port. Mais pour ce qui est de l'économie de circuits, c'est moins évident. Entre deux mémoires simple port et une mémoire multiport, la différence en termes de transistors est ambigüe et dépend de la capacité des caches. La différence est surtout notable pour les gros caches, moins pour les petits caches. Il faut aussi tenir compte de la capacité effective. Avec deux caches séparés, la répartition de la capacité du cache L1 est fixée une bonne fois pour toutes. Par exemple, avec un cache d'instruction de 32 KB et un cache de données de 32 KB, impossible d'allouer 40 KB aux données et 20 aux instructions. Alors qu'avec un cache L1 unique de 64 KB, on pourrait le faire sans soucis. La répartition se fait naturellement, en fonction de la politique de remplacement du cache et est proche de l'optimal. C'est là un désavantage des caches d'instructions/données séparés : une capacité effective moindre. Tout cela explique pourquoi le cache L1 est le seul à être ainsi scindé en deux, avec une séparation entre instructions et données : les contraintes au niveau du cache L1 et L2 ne sont pas les mêmes. Pour les caches L1, le temps d'accès est plus important que la capacité, ce qui favorise les caches séparés. Par contre, pour les caches L2/L3/L4, le temps d'accès n'est pas déterminant, alors que la capacité effective et l'économie en circuits sont significatives. ===La connexion des caches L1 avec le cache L2=== Pour les connexions avec le cache L2, tout dépend du processeur. Certains utilisent un cache L2 multiport, qui permet aux deux caches L1 de lire ou écrire dans le cache L2 simultanément. [[File:Cache d'instructions.png|centre|vignette|upright=1.5|Cache d'instructions.]] Si le cache L2 ne gère pas les accès simultanés, il n'y a qu'un seul bus relié aux caches L1 et au cache L2. On doit effectuer un arbitrage pour décider quel cache a la priorité, chose qui est réalisé par un circuit d'arbitrage spécialisé. [[File:Circuit d'arbitrage du cache.png|centre|vignette|upright=1.5|Circuit d'arbitrage du cache.]] Généralement, les caches d'instructions peuvent se permettre d'être plus petits que les caches de données, car les programmes sont souvent plus petits que les données manipulées. Songez que des programmes de quelques mébioctets peuvent parfois remplir la RAM avec plusieurs gibioctets de données. Lancez votre navigateur internet et ouvrez une page web un peu chargée, pour vous en convaincre ! ===Les spécificités du cache d'instruction : lecture seule, bloquant, etc=== Les instructions sont rarement modifiées ou accédées en écritures, contrairement aux données. Et cela permet d'utiliser un cache simplifié pour les instructions. Autant un cache généraliste doit permettre les lectures et écritures depuis le processeur (avec les échanges avec la RAM), autant un cache d'instruction peut se contenter des lectures provenant du CPU et des échanges avec la RAM. Le cache d'instructions est donc très souvent en « lecture seule » : le processeur ne peut pas écrire dedans, mais juste le lire ou charger des instructions dedans. Un cache d'instruction est donc plus simple qu'un cache pour les données : on peut retirer les circuits en charge de l'écriture (mais on doit laisser un port d'écriture pour charger les instructions dedans). Le gain en circuits permet d'utiliser un cache d'instruction plus gros ou au contraire de laisser de la place pour le cache de données. Le gain en termes de capacité compense alors un peu les inconvénients des caches séparés. Par contre, cela complique la gestion du code automodifiant, c'est-à-dire des programmes dont certaines instructions vont aller en modifier d'autres, ce qui sert pour faire de l'optimisation ou est utilisé pour compresser ou cacher un programme (les virus informatiques utilisent beaucoup de genre de procédés). Quand le processeur exécute ce genre de code, il ne peut pas écrire dans ce cache L1 d'instructions, mais doit écrire dans le cache L2 ou en RAM, avant de recharger les instructions modifiées dans le cache L1. Cela qui prend du temps et peut parfois donner lieu à des erreurs si le cache L1 n'est pas mis à jour. Les algorithmes de remplacement des lignes de cache optimaux pour les données ne le sont pas pour les instructions, de même que la taille optimale du cache, la taille des lignes de cache optimale, ou même les algorithmes de préchargement. Par exemple, pour le remplacement des lignes de cache, un simple algorithme LRU est presque optimal pour les instructions, autant il peut donner de mauvaises performances quand on manipule beaucoup de tableaux. Cela justifie d'utiliser des caches spécialisés pour chacune. On peut adapter le cache d'instruction à son contenu, ce qui le rend plus rapide ou plus petit à performance égale. Les caches d'instructions sont généralement des caches bloquants. Il ne servirait à rien de rendre un cache d'instruction non-bloquant, le cout en circuits ne se traduirait pas par une augmentation significative des performances. À l'opposé, les caches de données sont non-bloquants sur les architectures modernes, pour des raisons de performance. Ce qui rend la séparation assez intéressante, les deux caches ayant des besoins différents et des implémentations différentes, cela permet d'optimiser le cout en transistors des caches. ===L'impact du cache d'instruction sur les performances=== Sur les architectures conventionnelles, le cache d'instruction a plus d'impact sur les performances que le cache de données. La raison principale est que les instructions ont une meilleure localité spatiale et temporelle que pour les données. Pour la localité spatiale, les instructions consécutives se suivent en mémoire, alors que rien ne garantit que des données utilisées ensemble soient regroupées en mémoire. Pour localité temporelle, elle est très variable pour les données, mais très courante pour les instructions du fait de l'usage fréquent des boucles et des fonctions. : La présence de branchements atténue la localité temporelle des instruction, sauf que la majorité des branchements sautent à un endroit très proche, seuls les appels de fonction brisent la localité spatiale. La conséquence est qu'il arrive que certains CPU aient un cache L1 d'instruction plus gros que celui pour les données. On parle alors de '''cache L1 asymétriques'''. Un exemple est celui des processeurs AMD de microarchitecture Zen, dont le cache d'instruction était deux fois plus gros que le cache de données. Leur cache d'instruction faisait 64 kibioctets, contre seulement 32 pour le cache de données. D'ailleurs, il existe des processeurs assez extrêmes qui se contentent d'un cache d'instruction unique, sans cache de données. C'est le cas sur les processeurs vectoriels ou les GPU que nous verrons dans les chapitres de fin de ce wikilivres. De tels processeurs sont spécialisés dans la manipulation de tableaux de données, traitement qui a une faible localité temporelle. En conséquence, utiliser un cache de données n'est pas vraiment utile, voire peu être contreproductif, alors qu'un cache d’instruction fonctionne parfaitement. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Compléments sur les mémoires de masse | prevText=Compléments sur les mémoires de masse | next=Le préchargement | nextText=Le préchargement }} </noinclude> n51vl242gije05dg8gl7980854lzdi7 765169 765132 2026-04-26T20:43:27Z Mewtow 31375 765169 wikitext text/x-wiki Le cache est une mémoire intercalée entre la mémoire et un processeur, plus rarement à l'intérieur d'un périphérique. Il est souvent fabriquée avec de la mémoire SRAM, parfois avec de l'eDRAM. Sans lui, on se croirait à l'âge de pierre tellement nos PC seraient lents ! En effet, la mémoire est très lente comparée au processeur. Le temps mis pour accéder à la mémoire est du temps durant lequel le processeur n'exécute pas d'instruction (sauf cas particuliers impliquant un pipeline). Pour diminuer ce temps d'attente, il a été décidé d'intercaler une mémoire petite mais rapide, entre le processeur et la mémoire. Ainsi, le processeur accède à un cache très rapide plutôt qu'à une RAM beaucoup plus lente. ==L'accès au cache== Le cache contient une copie de certaines données présentes en RAM. La copie présente dans le cache est accessible bien plus rapidement que celle en RAM, vu que le cache est plus rapide. Mais seule une petite partie de ces données sont copiées dans le cache, les autres données devant être lues ou écrites dans la RAM. Toujours est-il que le cache contient une copie des dernières données accédées par le processeur. Une donnée est copiée dans la mémoire cache quand elle est lue ou écrite par le processeur. Le processeur conserve une copie de la donnée dans le cache après son premier accès. Les lectures/écritures suivantes se feront alors directement dans le cache. Évidemment, au fur et à mesure des accès, certaines données anciennes sont éliminées du cache pour faire de la place aux nouveaux entrants, comme nous le verrons plus tard. [[File:Principe d'une mémoire cache.gif|centre|vignette|upright=2|Principe d'une mémoire cache.]] La mémoire cache est invisible pour le programmeur, qui ne peut pas déceler celles-ci dans l'assembleur. Les accès mémoire se font de la même manière avec ou sans le cache. La raison à cela est que le cache intercepte les accès mémoire et y répond s'il en a la capacité. Par exemple, si le cache intercepte une lecture à une adresse et que le contenu de cette adresse est dans le cache, le cache va outrepasser la mémoire RAM et la donnée sera envoyée par le cache au lieu d'être lue en RAM. par contre, si un accès se fait à une adresse pour laquelle le cache n'a pas la donnée, alors l'accès mémoire sera effectué par la RAM de la même manière que si le cache n'était pas là. [[File:Accès au cache.png|centre|vignette|upright=2|Accès au cache]] ===Les succès et défauts de caches=== Tout accès mémoire est intercepté par le cache, qui vérifie si la donnée demandée est présente ou non dans le cache. Si la donnée voulue est présente dans le cache, on a un '''succès de cache''' (''cache hit'') et on accède à la donnée depuis le cache. Sinon, c'est un '''défaut de cache''' (''cache miss'') et on est obligé d’accéder à la RAM. Les défauts de cache peuvent avoir plusieurs origines. Tout ce qu'il faut savoir est que lorsque le processeur accède à une donnée ou une instruction pour la première fois, il la place dans la mémoire cache car elle a de bonnes chances d'être réutilisée prochainement. La raison à cela est qu'un programme a tendance à réutiliser les instructions et données qui ont été accédées dans le passé : c'est le ''principe de localité temporelle''. Bien évidement, cela dépend du programme, de la façon dont celui-ci est programmé et accède à ses données et du traitement qu'il fait, mais c'est souvent vrai en général. La première cause des défauts de cache est liée à la taille du cache. À force de charger des données/instructions dans le cache, le cache fini par être trop petit pour conserver les anciennes données. Le cache doit bien finir par faire de la place en supprimant les anciennes données, qui ont peu de chances d'être réutilisées. Ces anciennes données éliminées du cache peuvent cependant être accédées plus tard. Tout prochain accès à cette donnée mènera à un cache miss. C'est ce qu'on appelle un ''Capacity Cache Miss'', ou encore '''défaut de capacité'''. Les seules solutions pour éviter cela consistent à augmenter la taille du cache ou à optimiser le programme exécuté (voir plus bas). Une autre raison pour un défaut est donc la suivante. Lorsqu'on exécute à une instruction ou qu'on accède à donnée pour la première fois, celle-ci n'a pas encore été chargée dans le cache. Le défaut de cache est inévitable : ce genre de cache miss s'appelle un ''Cold Miss'', ou encore un '''défaut à froid'''. De tels défauts sont presque impossibles à éliminer, sauf à utiliser des techniques de préchargement qui chargent à l'avance des données potentiellement utiles. Ces méthodes de préchargement se basent sur le principe de localité spatiale, à savoir le fait que les programmes ont tendance à accéder à des données proches en mémoire. Pour donner un exemple, les instructions d'un programme sont placées en mémoire dans l’ordre dans lequel on les exécute : la prochaine instruction à exécuter est souvent placée juste après l'instruction en cours (sauf avec les branchements). Quand on accède à une donnée ou une instruction, le cache peut précharger les données adjacentes pour en profiter. Nous parlerons de ces techniques de préchargement dans un chapitre dédié, vers la fin du cours. ===Le fonctionnement du cache, vu du processeur=== Vu du processeur, le cache prend en entrée toutes les informations nécessaires pour effectuer un accès mémoire : des signaux de commande, une adresse et la donnée à écrire si besoin. Tout cela est passé en entrée du cache, celui-ci répondant aux accès mémoire via divers bits de contrôles, que le processeur peut lire à souhait. Le cache fournit aussi la donnée à lire, pour les lectures, sur une sortie, connectée directement au bus mémoire/processeur. Globalement, le cache a une capacité limitée, mais il prend en entrée des adresses complètes. Par exemple, sur un processeur 64 bits, le cache prend en entrée des adresses de 64 bits (sauf si optimisations), même si le cache en question ne fait que quelques mébioctets. Les caches sont souvent des mémoires multiports, surtout sur les processeurs récents. Les caches simple port sont rares, mêmes s'ils existent et ont existé par le passé. les caches double port sont eux plus fréquents, et ont généralement un port d'écriture séparé du port de lecture. Mais les caches récents ont plusieurs ports de lecture/écriture et sont capables de gérer plusieurs accès mémoire simultanés. Les données présentes dans le cache sont (pré)chargées depuis la mémoire, ce qui fait que toute donnée dans le cache est la copie d'une donnée en mémoire RAM. Le cache doit faire la correspondance entre une donnée du cache et l'adresse mémoire correspondante. Du point de vue du fonctionnement, on peut voir le cache comme une sorte de table de correspondance, qui mémorise des données, chacune étant associée à son adresse mémoire. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Cela vaut du point de vue du processeur, le fonctionnement interne du cache étant quelque peu différent selon le cache. Il existe des caches dont le fonctionnement interne est bien celui d'une table de correspondance matérielle, d'autres qui sont beaucoup plus optimisés. [[File:Fonctionnement d'une mémoire associative à correspondance.png|centre|vignette|upright=2|Fonctionnement simplifié d'une mémoire cache : les adresses sont dans la colonne de gauche, les données sont dans la colonne de droite. On voit qu'on envoie l'adresse au cache, que celui-ci répond en renvoyant la donnée associée.]] ==La performance des mémoires caches== L'analyse de la performance des mémoires caches est plus riche pour celle des autres mémoires. Sa performance dépend de beaucoup de paramètres, mais on peut cependant citer les principaux. Les deux premiers sont tout bonnement sa latence et son débit, comme pour n'importe quelle autre mémoire. La latence est plus importante que son débit, car le processeur est généralement plus rapide que le cache et qu'il n'aime pas attendre. Mais le critère le plus important pour un cache est sa capacité à empêcher des accès mémoire, son efficacité. Plus les accès mémoire sont servis par le cache au lieu de la RAM, meilleures seront les performances. Pour résumer, la performance d'un cache est surtout caractérisée par deux métriques : le taux de défaut, qui correspond à l’efficacité du cache, et la latence du cache. ===Le taux de succès/défaut=== Le '''taux de succès''' (hit ratio) est un premier indicateur des performances du cache, mais un indicateur assez imparfait. C'est le pourcentage d'accès mémoire qui ne déclenchent pas de défaut de cache. Plus il est élevé, plus le processeur accède au cache à la place de la RAM et plus le cache est efficace. Certains chercheurs préfèrent utiliser le '''taux de défauts''', à savoir le pourcentage d'accès mémoire qui entraînent un défaut de cache. Plus il est bas, meilleures sont les performances. Le taux de défaut est relié au taux de succès par l'équation <math>T_\text{succes} = 1 - T_\text{defaut}</math>. Par définition, il est égal à : : <math>\text{Taux de défauts de cache} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d’accès mémoires}}</math> Plutôt que de comparer le nombre de défauts/succès de cache au nombre d'accès mémoire, il est aussi possible de diviser le nombre de défauts par le nombre total d'instructions. On obtient alors le '''taux de défauts/succès par instruction''', une autre métrique utile. Par définition, elle est égale à : : <math>\text{Taux de défauts par instruction} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d'instructions}} = \text{Taux de défauts de cache} \times \frac{\text{Nombre d’accès mémoires}}{\text{Nombre d'instructions}}</math> Si certains défauts de cache sont inévitables quel que soit le cache, comme les défauts à froids, mentionnés plus haut, d'autres défauts peuvent être évités en augmentant la capacité du cache. C'est le cas des défauts de capacité qui sont causés par un accès à une donnée qui a été éliminée du cache faute de place. Plus le cache est gros, moins il a de chances d'être rempli, moins il doit rapatrier de données, plus son taux de succès augmente. Mais nous reviendrons sur le lien entre taille du cache et taux de défaut plus bas. Le taux de succès ne dépend pas que du cache, mais aussi de la conception des programmes exécutés. Une bonne utilisation du cache (ainsi que de la mémoire virtuelle) repose sur le programmeur qui doit prendre en compte les principes de localités dès la conception de ses programmes. Par exemple, un programmeur peut parfaitement tenir compte du cache au niveau de son algorithme : on peut citer l'existence des algorithmes ''cache oblivious'', qui sont conçus pour être optimaux quelle que soit la taille du cache. Le programmeur peut aussi choisir ses structures de données de manière à améliorer la localité. Par exemple, un tableau est une structure de donnée respectant le principe de localité spatiale, tandis qu'une liste chaînée ou un arbre n'en sont pas (bien qu'on puisse les implémenter de façon à limiter la casse). D'autres optimisations sont parfois possibles : par exemple, le sens de parcours d'un tableau multidimensionnel peut faire une grosse différence. Cela permet des gains très intéressants pouvant se mesurer avec des nombres à deux ou trois chiffres. Je vous recommande, si vous êtes programmeur, de vous renseigner le plus possible sur les optimisations de code ou algorithmiques qui concernent le cache : il vous suffira de chercher sur Google. Il y a une citation qui résume bien cela, prononcée par un certain Terje Mathisen. Si vous ne le connaissez pas, cet homme est un vieux programmeur (du temps durant lequel on codait encore en assembleur), grand gourou de l’optimisation, qui a notamment travaillé sur le moteur de Quake 3 Arena. {{BlocCitation|Almost all programming can be viewed as an exercise in caching.|auteur=Terje Mathisen}} ===La latence moyenne d'un cache=== Le temps mis pour lire ou écrire une donnée varie en présence d'un cache. Certaines lectures/écritures vont atterrir directement dans le cache (succès) tandis que d'autres devront aller chercher leur contenu en mémoire RAM (défaut de cache). Dans tous les cas, qu'il y ait défaut ou non, le cache sera consulté et mettra un certain temps à répondre, égal au temps de latence du cache. Tous les accès mémoires auront donc une durée au moins égale au temps de latence du cache, qui sera notée <math>T_c</math>. En cas de succès, le cache aura effectué la lecture ou l'écriture, et aucune action supplémentaire n'est requise. Ce qui n'est pas le cas en cas de défaut : le processeur devra aller lire/écrire la donnée en RAM, ce qui prend un temps supplémentaire égal au temps de latence de la mémoire RAM. Un défaut ajoute donc un temps, une pénalité, à l'accès mémoire. Dans ce qui suivra, le temps d'accès à la RAM sera noté <math>T_m</math>. Fort de ces informations, nous pouvons calculer le temps de latence moyen d'un accès mémoire, qui est la somme du temps d'accès au cache (pour tous les accès mémoire), multiplié par le temps lié aux défauts. On a alors : : <math>T = T_c + \text{Taux de défaut} \times T_m</math> On voit que plus le taux de succès est élevé, plus le temps de latence moyen sera bas, et inversement. Ce qui explique l'influence du taux de succès sur les performances du cache, influence assez importante sur les processeurs actuels. De nos jours, le temps que passe le processeur dans les défauts de cache devient de plus en plus un problème au fil du temps, et gérer correctement le cache est une nécessité, particulièrement sur les processeurs multi-cœurs. Il faut dire que la différence de vitesse entre processeur et mémoire est tellement importante que les défauts de cache sont très lents : alors qu'un succès de cache va prendre entre 1 et 5 cycles d'horloge, un cache miss fera plus dans les 400-1000 cycles d'horloge. Tout ce temps sera du temps de perdu que le processeur aura du mal à mitiger. Autant dire que réduire les défauts de cache est beaucoup plus efficace que d'optimiser les calculs effectués par le processeur (erreur courante chez de nombreux programmeurs, notamment débutants). ===L'impact de la taille du cache sur le taux de défaut et la latence=== Il y a un lien entre taille du cache, taux de défaut, débit binaire et latence moyenne. Globalement, plus un cache est gros, plus il est lent. Simple application de la notion de hiérarchie mémoire vue il y a quelques chapitres. Les raisons à cela sont nombreuses, mais nous ne pouvons pas les aborder ici, car il faudrait que nous sachions comment fonctionne un cache et ce qu'il y a à l'intérieur, ce qui sera vu dans la suite du chapitre. Toujours est-il que la latence moyenne d'un cache assez gros est assez importante. De même, le débit binaire d'un cache diminue avec sa taille, mais dans une moindre mesure. Les petits caches ont donc un gros débit binaire et une faible latence, alors que c'est l'inverse pour les gros caches. Une grande capacité de cache améliore le taux de succès, mais cela se fait au détriment de son temps de latence et de son débit, ce qui fait qu'il y a un compromis assez difficile à trouver entre taille du cache, latence et débit. Il peut arriver qu'augmenter la taille du cache augmente son temps d'accès au point d’entraîner une baisse de performance. Par exemple, les processeurs Nehalem d'Intel ont vus leurs performances dans certains jeux vidéos baisser de 2 à 3 %, malgré de nombreuses améliorations architecturales, parce que la latence du cache L1 avait augmentée de 2 cycles d'horloge. Pour avoir une petite idée du compromis à faire, regardons la relation entre taille du cache et taux de défaut. Il existe une relation approximative entre ces deux variables, appelée la '''loi de puissance des défauts de cache'''. Elle donne le nombre total de défaut de cache en fonction de la taille du cache et de deux autres paramètres. Voici cette loi : : <math>\text{Taux de défauts de cache} \approx K \times \text{Taille du cache}^{- \alpha }</math>, avec <math>K</math> et <math>\alpha</math> deux coefficients qui dépendent du programme exécuté. Le coefficient <math>\alpha</math> est généralement compris entre 0.3 et 0.7, guère plus, et varie suivant le programme exécuté. Précisons que cette loi ne marche que si le cache est assez petit par rapport aux données à utiliser. Pour un cache assez gros et des données très petites, la relation précédente est mise en défaut. Pour s'en rendre compte, il suffit d'étudier le cas extrême où toutes les données nécessaires tiennent dans le cache. Dans ce cas, il n'y a qu'un nombre fixe de défauts de cache : autant qu'il faut charger de données dans le cache. Le nombre de défauts de cache observé dans cette situation n'est autre que le coefficient <math>K</math> de la situation précédente, mais il n'y a aucune dépendance entre taux de défaut et taille du cache. L'origine de cette relation s'explique quand on regarde combien de fois chaque donnée est réutilisée lors de l’exécution d'un programme. La plupart des données finissent par être ré-accédées à un moment ou un autre et il se passe un certain temps entre deux accès à une même donnée. Sur la plupart des programmes, les observations montrent que beaucoup de réutilisations de données se font après un temps très court et qu'inversement, peu de ré-accès se font après un temps inter-accès long. Si on compte le nombre de réutilisation qui ont un temps inter-accès bien précis, on retrouve une loi de puissance identique à celle vue précédemment : : <math>\text{Nombre de réaccès avec un temps inter-accès égal à t} \approx K \times t^{- \beta}</math>, avec t le temps moyen entre deux réutilisations. Le coefficient <math>\beta</math> est ici compris entre 1.7 et 1.3. De manière générale, les coefficients <math>\alpha</math> et <math>\beta</math> sont reliés par la relation <math>\alpha = 1 - \beta</math>, ce qui montre qu'il y a un lien entre les deux relations. Précisons cependant que la loi de puissance précédente ne vaut pas pour tous les programmes informatiques, mais seulement pour la plupart d’entre eux. Il n'est pas rare de trouver quelques programmes pour lesquels les accès aux données sont relativement prédictibles et où une bonne optimisation du code fait que la loi de puissance précédente n'est pas valide. La loi de puissance des défauts de cache peut se démontrer à partir de la relation précédente, sous certaines hypothèses. Si un suppose que le cache est assez petit par rapport aux données, alors les deux relations sont équivalentes. L'idée qui se cache derrière la démonstration est que si le temps entre deux accès à une donnée est trop long, alors la donnée accédée aura plus de chance d'être rapatriée en RAM, ce qui cause un défaut de cache. La chance de rapatriement dépend de la taille du cache, un cache plus gros peut conserver plus de données et a donc un temps avant rapatriement plus long. ==Les lignes de cache et leurs tags== Du point de vue du processeur, les lectures et écritures se font mot mémoire par mot mémoire. Un processeur avec des entiers de 64 bits recoit des données de 64 bits de la part du cache, et y écrit des mots de 64 bits. Mais quand on regarde comment sont stockées les données à l'intérieur du cache, les choses sont différentes. ===Les lignes de cache=== Les données sont mémorisées dans le cache par blocs de plusieurs bytes, d'environ 64 à 256 octets chacun, qui portent le nom de '''lignes de cache'''. Les lignes de cache sont l'unité de stockage que l'on trouve à l'intérieur du cache, mais elles servent aussi d'unité de transaction avec la mémoire RAM. Sur les caches actuels, on transfère les données entre le cache et la RAM ligne de cache par ligne de cache, dans la limite de la taille du bus mémoire. Mais d'autres caches plus anciens permettaient de faire des transferts plus fins. C’est-à-dire qu'on pouvait mettre à jour quelques octets dans une ligne de cache sans avoir à la recopier intégralement depuis ou dans la mémoire RAM. En théorie, on pourrait imaginer des caches où les données sont stockées différemment, où l'unité serait le mot mémoire, par exemple. Par exemple, sur un processeur 64 bits, on aurait une ligne de cache de 64 bits. Cela aurait l'avantage de la simplicité : les transferts entre le processeur et la mémoire serait de même taille, l'intérieur du cache ressemblerait à son interface montrée au processeur. Mais cela aurait quelques défauts qui sont compensés par l'organisation en lignes de cache de grande taille. Le premier avantage des lignes de cache est lié à la localité spatiale, la tendance qu'on les programmes à accéder à des données proches les unes des autres. Des accès mémoires consécutifs ont tendance à se faire à des adresses proches, qui ont de bonnes chances d'être dans la même ligne de cache. Et des accès consécutifs à une même ligne de cache sont plus rapides que des accès à deux lignes distinctes. Une autre raison est tout simplement que cela simplifie considérablement la circuiterie du cache. Pour une capacité identique, il vaut mieux avoir peu de lignes de cache assez grosses, que beaucoup de petites lignes de cache. La raison est que les circuits du cache, comme le décodeur, l'encodeur et autres, ont moins de sorties et sont donc plus simples. ===L'alignement des lignes de cache=== Les lignes de cache sont des blocs de plusieurs dizaines à centaines de bytes, dont la taille est presque toujours une puissance de deux. De plus, les lignes de cache sont alignées en mémoire. Nous avions déjà abordé la notion d'alignement mémoire dans un chapitre précédent, mais le concept d'alignement des lignes de cache est quelque peu différent. Quand nous avions parlé d'alignement auparavant, il s'agissait de l'alignement des données manipulées par le processeur, qui faisait partie du jeu d'instruction du processeur. Ici, nous parlons d'un alignement totalement différent, invisible pour le programmeur, sans lien avec le jeu d’instruction. Voyons de quoi il retourne. Concrètement, cela veut dire que du point de vue du cache, la RAM est découpée en blocs qui font la même taille qu'une ligne de cache, aux positions prédéterminées, sans recouvrement entre les blocs. Par exemple, pour un cache dont les lignes de cache font 256 octets, le premier bloc est à l'adresse 0, le second est 256 octets plus loin, c'est à dire à l'adresse 256, le troisième à l'adresse 512, la quatrième à l'adresse 768, etc. Une ligne de cache de 256 octets contiendra une donnée provenant d'un bloc de RAM de 256 octets, dont l'adresse est systématiquement un multiple de 256. Il n'est pas possible qu'une ligne de cache contienne un bloc de 256 octets dont l'adresse du premier octet serait l'adresse 64, ou l'adresse 32, par exemple. En clair, les adresses de ces blocs sont des multiples de la taille de la ligne de cache, de la taille des blocs. Cela rappelle les contraintes d'alignement vues dans le chapitre "Le modèle mémoire : alignement et boutisme", mais appliquées aux lignes de cache. L'alignement des lignes de cache a des conséquences pratiques pour la conception des caches. Notons qu'il est en théorie possible d'avoir des caches dont les lignes de cache ne sont pas alignées, mais cela poserait des problèmes majeurs. Il serait en effet possible qu'une donnée soit présente dans deux lignes de cache à la fois. Par exemple, prenons le cas où une ligne de cache de 256 commence à l'adresse 64 et une autre ligne de cache commence à l'adresse 0. L'adresse 128 serait dans les deux lignes de cache ! Et cela poserait des problèmes lors des lectures, mais encore plus lors des écritures. C'est pour éviter ce genre de problèmes que les lignes de cache sont alignées avec la mémoire RAM dans tous les caches existants. L'alignement des lignes de cache est une chose que les programmeurs doivent parfois prendre en compte quand ils écrivent du code ultra-optimisé, destiné à des programmes demandant des performances extrêmes. Il arrive que les contraintes d'alignement posent des problèmes. Nous avions vu dans le chapitre sur le boutisme et l'alignement qu'il valait mieux gérer l'alignement des variables des structures de données, pour éviter les accès non-alignés avec le bus mémoire. La même chose est possible, mais pour l'alignement avec des lignes de cache. Typiquement, l'idéal est que, pour une structure de donnée, on puisse en mettre un nombre entier dans une ligne de cache. Ou alors, si la structure est vraiment grande, que celle-ci occupe un nombre entier de lignes de cache. Si ce n'est pas le cas, il y a un risque d'accès non-alignés, c'est à dire qu'une structure se retrouve à cheval sur deux lignes de cache, avec les défauts que cela implique. ===Le tag d'une ligne de cache=== Plus haut, nous avions dit que le cache mémorise, pour chaque ligne de cache, l'adresse RAM associée. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Mais du fait de l'organisation du cache en lignes de cache de grande taille, qui sont de plus alignées en mémoire, il faut nuancer cette affirmation. Le cache ne mémorise pas la totalité de l'adresse, ce qui serait inutile. L'alignement des lignes de cache en RAM fait que les bits de poids faible de l'adresse ne sont pas à prendre en compte pour l'association adresse-ligne de cache. Dans ces conditions, on mémorise seulement la partie utile de l'adresse mémoire correspondante, qui forme ce qu'on appelle le '''tag'''. Le reste de l'adresse indique quelle est la position de la donnée dans la ligne de cache. Par exemple, prenons le cas où le processeur gère des nombres entiers de 64 bits (8 octets) et des lignes de cache de 128 octets : chaque ligne de cache contient donc 16 entiers. Si le processeur veut lire ou écrire un entier bien précis, il doit préciser sa place dans la ligne de cache. Et ce sont les bits de l'adresse mémoire non-inclus dans le cache qui permettent de faire ça. En clair, une adresse mémoire à lire/écrire est interprété par le cache comme la concaténation d'un tag et de la position de la donnée dans la ligne de cache correspondante. [[File:Adressage d'un cache totalement associatif.png|centre|vignette|upright=2|Adressage d'un cache totalement associatif]] Le cache est donc une grande table de correspondance entre tags et lignes de cache. Lors d'un accès mémoire, le cache extrait le tag de l'adresse à lire ou écrire, et le compare avec les tags de chaque ligne de cache. Si une ligne contient ce tag, alors c'est que cette ligne correspond à l'adresse, et c'est un défaut de cache sinon. Lors d'un succès de cache, la ligne de cache est lue depuis le cache et envoyée à un multiplexeur qui sélectionne la donnée à lire dans la ligne de cache. Le fonctionnement est similaire pour une écriture : la donnée à écrire passe dans un démultiplexeur, qui envoie la donnée au bon endroit dans la ligne de cache sélectionnée. [[File:Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.png|centre|vignette|upright=2|Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.]] ===Le contenu d'une ligne de cache=== Dans ce qui va suivre, nous allons considérer que chaque ligne de cache mémorise son tag, les données de la ligne de cache proprement dit, et quelques bits de contrôle annexes qui varient suivant le cache considéré. [[File:Tag d'une ligne de cache.png|centre|vignette|upright=2|Tag d'une ligne de cache.]] Les caches modernes incluent de nombreux bits de contrôle, mais deux d'entre eux sont communs à presque tous les caches modernes : le bit ''Dirty'' et le bit ''Valid''. Le '''bit ''Valid''''' indique si la ligne de cache contient des données valides ou non. Si le bit ''Valid'' est à 0, la ligne de cache est en état valide, à savoir qu'elle contient des données et n'est pas vide. Par contre, si ce bit est à 1, la ligne de cache est invalide et son contenu ne peut pas être lu ou écrit. L'utilité de ce bit est qu'il permet d'effacer une ligne de cache très rapidement : il suffit de mettre ce bit à 0. Il existe des situations où le cache doit être effacé, on dit alors qu'il est invalidé. Une section de ce chapitre sera dédié à l'invalidation du cache. Le '''bit ''Dirty''''' indique qu'une ligne de cache a été modifiée. Par modifiée, on veut dire que le processeur a écrit dedans, qu'il a modifié la ligne de cache. Mais attention : si la donnée a été modifiée dans le cache, la modification n'est pas forcément propagée en mémoire RAM. Le bit ''dirty'' indique si c'est le cas, si l'écriture a été propagée en mémoire RAM. Il précise que la ligne de cache contient des données modifiées, alors que la RAM a des données initiales non-modifiées. Une ligne de cache avec un bit ''dirty'' à 1 est dite ''dirty'', par métonymie. Nous verrons cela en détail dans la section sur les caches ''write-back'' et ''write-through''. Les caches modernes ajoutent des '''bits de détection/correction d'erreur''' dans les bits de contrôle. Pour rappel, les codes de détection/correction d'erreur permettent de se prémunir contre des erreurs matérielles, qui corrompent les données stockées dans une mémoire, ici une mémoire cache. Ils ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Nous reviendrons dessus dans une section ultérieur de ce chapitre. Sur certains caches assez anciens, on pouvait transférer les lignes de caches morceaux par morceaux. Ces caches avaient des lignes de cache divisées en sous-secteurs, ces sous-secteurs étant des morceaux de ligne de cache qu'on pouvait charger indépendamment les uns des autres (mais qui sont consécutifs en RAM). Chaque secteur avait ses propres bits de contrôle, mais le tag était commun à tous les secteurs. [[File:Cache à secteurs.png|centre|vignette|upright=2.5|Cache à secteurs.]] : Dans ce qui va suivre, le terme "ligne de cache" désignera soit un bloc de données copiées depuis la RAM d'une taille de 64/128/256/... octets, soit la concaténation de ces données avec le tag et des bits de contrôle. Les deux définitions ne sont pas équivalentes, mais l'usage a entériné cet abus de langage. Et il faut avouer que cela rend les explications du chapitre plus simples. ==Les instructions de contrôle du cache== Plus haut, nous avions dit que le cache est totalement transparent du point de vue du programmeur. Le cache contient des copies de données en RAM, le programmeur n'a rien à faire pour utiliser le cache correctement. Mais la réalité est que pour des raisons diverses, des processeurs incorporent des '''instructions de contrôle du cache'''. Il s'agit d’instructions qui agissent sur le contenu du cache. Elles existent pour des raisons diverses qu'on détaillera plus bas, mais il s'agit globalement d'une question de performances ou de nécessité pour le système d'exploitation. ===Les instructions de préchargement=== La première instruction de contrôle du cache est une '''instruction de préchargement''', qui demande à charger un bloc de données dans le cache. Elle prend en opérande une adresse mémoire, et le contenu de cette adresse est chargé dans une ligne de cache. Bien sûr, des contraintes d'alignement sont à prendre en compte : on charge un bloc de la même taille qu'une ligne de cache, aligné en mémoire sur la taille du bloc, qui contient l'adresse. L'instruction de préchargement n'est utile que si l'instruction est exécutée bien avant que la donnée ne soit utilisée/lue/écrite. Cela permet de charger une donnée dans le cache à l'avance, d'où le nom de préchargement donné à cette technique. Mais les processeurs modernes gérent des techniques de préchargement automatique, qui ne requièrent pas d'instructions de préchargement. Le préchargement automatique et les instructions de préchargement sont deux solutions complémentaires, mais qui peuvent se marcher sur les pieds. Nous en reparlerons dans le prochain chapitre, qui sera dédié au préchargement automatique. Il faut noter que les instructions de préchargement peuvent être ignorées par le processeur. Sous certaines conditions, le processeur peut décider que l'instruction de préchargement ne sera pas exécutée. Par exemple, il ne va pas précharger une donnée déjà présente dans le cache. Ou encore, si le bus mémoire est occupé, il ne va pas exécuter le préchargement, par manque de ressources matérielles. ===Les instructions d'invalidation et de ''flush''=== Les instructions ''flush'' regroupent deux types d'instructions qui sont souvent utilisées en même temps. Il s'agit des instructions d'invalidation et de nettoyage (''clean''). Les deux termes proviennent de la terminologie ARM, il n'y a pas de terminologie standardisé pour les noms de ces instructions. Dans les grandes lignes, elles permettent de vider le cache, à savoir de rapatrier son contenu en RAM et de réinitialiser le cache à zéro. Elles sont utilisées par le système d'exploitation lors des commutations de contexte, à savoir quand on passe d'un programme à un autre. Elles sont aussi utilisées lors des appels systèmes et routines d'interruption/exception. L'idée est de vider le cache avant d'exécuter un nouveau programme ou une nouvelle routine. Le nouveau programme aura accès à un cache tout propre, les données de l'ancien programme auront été retirée du cache. Les '''instructions ''clean''''' recopient le contenu de la ligne de cache en RAM. Elles forcent la recopie immédiatement de la ligne de cache en mémoire RAM. Pour faire leur travail, elle vérifient si la ligne de cache a été modifiée, avant de la recopier en RAM. Et pour cela, ils vérifient le bit de contrôle ''dirty'', qui est mis à 1 après une première écriture. Si ce bit est à 0, alors pas besoin de recopier la ligne de cache : elle n'a pas été modifiée, la RAM a déjà la bonne copie. Mais s'il est à 1, le cache et la RAM n'ont pas le même contenu, la recopie s'exécute. Les '''instructions d'invalidation''' permettent d'invalider une ligne de cache, à savoir d'effacer son contenu. Nous verrons à quoi servent ces instructions dans la section sur les changement de processus. Invalider une ligne de cache est une opération optimisée : le cache n'est en réalité pas réellement effacé. À la place, le bit ''Valid'' de chaque ligne de cache est juste mis à 0. Il faut noter que l'invalidation efface les lignes de cache sans se préoccuper de leur contenu. Elle se moque qu'une ligne de cache contienne une donnée modifiée, ''dirty'' ou quoique ce soit : la ligne de cache est effacée, point. Il est possible d'invalider une ligne de cache en fournissant une adresse mémoire, mais il est aussi possible d'invalider le cache tout entier. Le choix entre les deux dépend du mode d'adressage de l'instruction d'invalidation. Parfois, il existe une instruction séparée pour invalider tout le cache, et une autre pour invalider une ligne de cache bien précise. Des instructions séparées sont parfois disponibles pour invalider les caches de données et d'instructions, parfois aussi la TLB (un cache qu'on verra dans quelques chapitres). Il est possible de n'invalider que le cache L1, voire le cache L2. Il faut noter que l'invalidation efface tout le cache, mais ne se préoccupe pas de vérifier si les données ont été modifiées dans le cache. Pour certains caches, comme le cache d'instruction, ce n'est pas un problème, vu qu'il est en "lecture seule". Mais pour les caches de données, les données modifiées sont perdues en cas d'invalidation. Heureusement, il existe des instructions d'invalidation qui fusionnent une instruction ''clean'' et une instruction d'invalidation. Il s'agit d''''instructions d'invalidation spéciales'''. ===Les instructions d'optimisation : instructions non-temporelles et écritures optimisées=== Les '''instructions mémoire non-temporelles''' contournent complètement le cache. Par exemple, une lecture peut lire une donnée, mais celle-ci ne sera pas chargée dans le cache, elle passe directement de la RAM vers les registres. Une section entière de ce chapitre sera dédiée au contournement du cache, à savoir aux situations où les accès mémoire doivent passer directement du processeur à la RAM sans passer par le cache. D'autres instructions assez rares incorporent des indications pour le cache. Par exemple, l'instruction ''load last'' des processeurs POWER PC implique que la donnée ne sera utilisée qu'une seule fois. Elle est donc chargée dans le cache, mais la ligne de cache est configurée de manière à être remplacée très rapidement, typiquement avec une valeur de LRU/LFU adéquate. La donnée est bien chargée dans le cache, au cas où elle doive être relue suite à une mauvaise prédiction de branchement ou autre, chose qu'une lecture non-temporelle (qui contourne le cache) ne fait pas. Des indications de ce type sont appelées des '''''cache hint'''''. L''''instruction ''flush''''' permet de préciser qu'une ligne de cache contient une donnée inutile, qui ne sera pas réutilisée par le programme. Pas besoin de la conserver dans le cache, elle peut laisser sa place à des données plus utiles. Or, sans indication, les algorithmes de remplacement d'une ligne de cache risquent de conserver cette donnée trop longtemps, ce qui entraine une certaine pollution du cache par des données inutiles. Une autre instruction est elle beaucoup plus importante : celle de '''pré-allocation sur écriture'''. Elle sert dans le cas où une ligne de cache est complétement écrite. Par exemple, imaginons qu'on veuille écrire dans une portion de mémoire. Si celle-ci n'est pas dans le cache, le processeur va charger une ligne de cache complète depuis la RAM, écrire dans la ligne de cache, puis recopier la ligne de cache modifiée en mémoire RAM. Une écriture en RAM demande donc de faire une lecture et une écriture. Mais les instructions de pré-allocation sur écriture permettent de prévenir qu'une ligne de cache sera intégralement écrite, et qu'il n'y a donc pas besoin de lire celle-ci depuis la RAM. Notons que l'instruction d'écriture qui suit n'est pas une écriture non-temporelle, vu que les données sont écrites dans la ligne de cache, qui est ensuite envoyée en mémoire RAM dès que nécessaire. De plus, les données écrites peuvent ensuite être relue depuis le cache si nécessaire. Enfin, certains processeurs MIPS incorporent une instruction pour modifier le tag d'une ligne de cache. Elles servent à optimiser les copies mémoire, à savoir quand on copie un bloc de données d'un endroit à un autre. L'idée est de charger le bloc de données dans le cache avec une instruction LOAD/PREFETCH, de modifier le tag pour qu'il pointe vers l'adresse à écrire, et de laisser faire le cache pour que l'écriture se fasse en RAM. Mais les contraintes pour utiliser cette instruction sont assez drastiques : les données doivent être alignées sur la taille d'une ligne de cache, le bloc de départ et d'arrivée (l'original versus la copie) ne doivent pas se recouvrir, etc. ==L'associativité des caches et leur adressage implicite== Lorsqu'on souhaite accéder au cache, il faut trouver quelle est la ligne de cache dont le tag correspond à l'adresse demandée. On peut classifier les caches selon leur stratégie de recherche de la ligne correspondante en trois types de caches : totalement associatifs, directement adressés (''direct mapped'') et associatifs par voie. ===Les caches totalement associatifs=== Avec les caches totalement associatifs, toute donnée chargée depuis la mémoire peut être placée dans n'importe quelle ligne de cache, sans aucune restriction. Ces caches ont un taux de succès très élevé, quand on les compare aux autres caches. [[File:Cache totalement associatif.png|centre|vignette|upright=2|Cache totalement associatif.]] Concevoir un cache totalement associatif peut se faire de deux grandes manières différentes. La première consiste tout simplement à combiner une mémoire associative avec une mémoire RAM, en ajoutant éventuellement quelques circuits annexes. La mémoire associative mémorise les tags, alors que la mémoire RAM mémorise les données de la ligne de cache, éventuellement avec quelques bits de contrôle. La ligne de cache est stockée à une adresse A dans la mémoire RAM et son tag est stocké à la même adresse, mais dans la mémoire CAM. Ce faisant, quand on envoie le tag à la mémoire CAM, elle renvoie l'adresse de la ligne de cache dans la mémoire RAM. Cette adresse est alors envoyée directement sur le bus d'adresse de la RAM, et la lecture est effectuée automatiquement. Il faut ajouter quelques circuits annexes pour garantir que les écritures se passent correctement dans les deux mémoires, mais rien de bien terrible. [[File:Cache fabriqué avec une mémoire associative et une RAM.png|centre|vignette|upright=3|Cache fabriqué avec une mémoire associative et une RAM]] Il est cependant possible d'optimiser un tel cache, en fusionnant la mémoire CAM et la mémoire RAM, afin d'éliminer des circuits redondants. Pour comprendre pourquoi, rappelons que les mémoires CAM sont composées d'un plan mémoire, d'un paquet de comparateurs et d'un encodeur. Quant à la mémoire RAM, elle est composée d'un décodeur connecté au plan mémoire. En mettant une CAM suivie d'une RAM, on a un encodeur dont l'entrée est envoyée à un décodeur. [[File:Cache totalement associatif naif.png|centre|vignette|upright=3|Cache totalement associatif naif]] Or, le décodeur réalise l'opération inverse de l'encodeur, ce qui fait que mettre les deux composants à la suite ne sert à rien. On peut donc retirer l'encodeur et le décodeur, et envoyer directement les résultats des comparateurs sur les entrées de commande du plan mémoire de la RAM. [[File:Cache totalement associatif optimisé.png|centre|vignette|upright=2|Cache totalement associatif optimisé]] Avec cette méthode, les circuits du cache ressemblent à ce qui illustré ci-dessous. Le tag est envoyé à chaque ligne de cache. Le tag envoyé est alors comparé avec le Tag contenu dans chaque ligne de cache, comme c'est le cas sur les mémoires associatives. Si une ligne de cache matche avec le tag envoyé en entrée, la ligne pour laquelle il y a eu une égalité est alors connectée sur les lignes de bit (''bitlines''). Cela est réalisé par un circuit commandé par le comparateur de la ligne de cache. Il ne reste plus qu'à sélectionner la portion de la ligne de cache qui nous intéresse, grâce à un paquet de multiplexeurs. Cela permet d'effectuer une lecture ou écriture, mais il faut aussi préciser si il y a eu un défaut de cache ou un succès. Un succès de cache a lieu quand au moins des comparaisons est positive, alors que c'est un défaut de cache sinon. En clair, détecter un succès de cache demande juste de connecter une porte OU à plusieurs entrées à tous les comparateurs. [[File:Organisation générale d'un cache totalement associatif.png|centre|vignette|upright=2|Organisation générale d'un cache totalement associatif.]] ===Les caches directement adressés=== Les caches directement adressés peuvent être vus comme un cache totalement associatif auquel on aurait ajouté des restrictions assez drastiques. Plus haut, on a vu qu'un cache totalement adressé est équivalent à la combinaison d'une CAM avec une RAM. La mémoire CAM prend en entrée un Tag et traduit celui-ci en une adresse qui commande la mémoire RAM interne au cache. Dans ce qui suit, l'adresse interne au cache sera appelé l''''indice''' pour éviter toute confusion. [[File:Cache hash table - 2.png|centre|vignette|upright=2|Fonctionnement interne du cache, expliquée sous forme abstraite, en utilisant la notion d'indice interne au cache.]] Les caches directement adressés cherchent à remplacer la mémoire CAM par un circuit combinatoire. Ce circuit traduit le Tag en indice, mais est beaucoup plus simple qu'une mémoire CAM. Mais qui dit circuit plus simple dit circuit plus limité. Un circuit combinatoire n'est pas aussi versatile que ce qui est permis avec une mémoire CAM. En conséquence, une restriction majeure apparait : toute adresse mémoire est associée dans une ligne de cache prédéfinie, toujours la même. L'association entre ligne de cache et adresse mémoire est faite par le circuit combinatoire, et ne peut pas changer. Les concepteurs de caches s'arrangent pour que des adresses consécutives en mémoire RAM occupent des lignes de cache consécutives, par souci de simplicité. Tout se passe comme suit la mémoire RAM était découpés en blocs de la même taille que le cache. La première adresse du bloc est associée à la première ligne de cache (celle d'indice 0), la seconde adresse est associée à la seconde adresse du_ bloc, et ainsi de suite. Le tout est illustré ci-dessous. [[File:Cache adressé directement.png|centre|vignette|upright=2|Cache adressé directement.]] Avec cette contrainte, le circuit de traduction de l'adresse en adresse mémoire pour la RAM interne au cache est drastiquement simplifié, et disparait même. Une partie de l'adresse mémoire sert à indiquer la position de la donnée dans le cache, le reste de l'adresse sert encode le tag et la position de la donnée dans le ligne de cache. [[File:Cache line.png|centre|vignette|upright=2|Adresse d'une ligne de cache sur un cache adressé directement.]] Un cache directement adressé est conçu avec une RAM, un comparateur, et un paquet de multiplexeurs. En général, la mémoire RAM stocke les lignes de caches complète. Il arrive que l'on utilise deux mémoires RAM : une pour les tags et une pour les données, mais cette technique augmente le nombre de circuits et de portes logiques nécessaires, ce qui réduit la capacité du cache. L'index à lire/écrire est envoyé sur l'entrée d'adresse de la RAM, la RAM réagit en mettant la ligne de cache sur sa sortie de donnée. Sur cette sortie, un comparateur compare le tag de la ligne de cache lue avec le tag de l'adresse à lire ou écrire. On saura alors si on doit faire face à un défaut de cache. Ensuite, un multiplexeur récupère la donnée à lire/écrire. [[File:Direct mapped cache - french.png|centre|vignette|upright=2|Cache directement adressé.]] L'accès à un cache directement adressé a l'avantage d'être très rapide vu qu'il suffit de vérifier une seule ligne de cache : celle prédéfinie. Mais ces caches ne sont cependant pas sans défauts. Vu que le cache est plus petit que la mémoire, certaines adresses mémoires se partagent la même ligne de cache. Si le processeur a besoin d’accéder fréquemment à ces adresses, chaque accès à une adresse supprimera l'autre du cache : tout accès à l'ancienne adresse se soldera par un défaut de cache. Ce genre de défauts de cache causés par le fait que deux adresses mémoires ne peuvent utiliser la même ligne de cache s'appelle un '''défaut par conflit''' (''conflict miss''). Les défauts par conflit n'existent pas sur les caches totalement associatifs. En conséquence, le taux de succès des caches directement adressés est assez faible comparé aux autres caches. [[File:Cache Block Basic Conflict.svg|centre|vignette|upright=1.5|Exemple de ''Conflict Miss''.]] ===Les caches associatifs par voie=== Les caches associatifs par voie sont un compromis entre les caches directement adressés et les caches totalement associatifs. Pour simplifier, ces caches sont composés de plusieurs caches directement adressés accessibles en parallèle, chaque cache/RAM étant appelé une '''voie'''. Avec ces caches, toute adresse mémoire en RAM est associée à une ligne de cache dans chaque voie. [[File:Cache associatif par voie.png|centre|vignette|upright=2|Cache associatif par voie.]] Le schéma ci-dessous compare un cache directement adressé et un cache associatif à deux voies. On voit que chaque adresse est associée à une ligne de cache bien précise avec un cache directement dressé, et à deux lignes de cache avec un cache associatif à deux voies. L'adresse sera associée à 4 lignes de cache sur un cache associatif à 4 voies, à 8 lignes pour un cache à 8 voies, etc. L'ensemble des lignes de cache associées à une adresse est appelé un '''ensemble'''. [[File:Cache Fill.svg|centre|vignette|upright=2|Comparaison entre un cache directement adressé et un cache associatif à deux voies.]] Sur ces caches, toute adresse est découpée en trois parties : un tag, un index, et un décalage, comme sur les caches directement adressés. Comme vous pouvez le voir, l'organisation est identique à celle d'un cache totalement associatif, à part que chaque ensemble tag-ligne de cache est remplacé par une mémoire RAM qui en contient plusieurs. [[File:Implémentation d'un cache associatif par voie.png|centre|vignette|upright=2|Implémentation d'un cache associatif par voie.]] Le risque de conflits d'accès au cache est donc réduit sur un cache associatif à plusieurs voies, et il est d'autant plus réduit que le cache a de voies. Par contre, leur conception interne fait qu'ils ont un temps d'accès légèrement élevé que les caches directement adressés. Les caches associatifs par voie ont donc un taux de succès et un temps d'accès intermédiaire, situé entre les caches directement adressés et totalement associatifs. Ils sont une sorte de compromis entre réduction des défaut par conflits d'accès au cache et temps d'accès, et complexité des circuits. ==Les optimisations des caches associatifs par voie== Les caches partiellement associatifs regroupent les caches associatifs par voie et directement adressés, ainsi que leurs variantes. En clair : tous les caches qui ne sont pas totalement associatifs. Ils peuvent être optimisés de nombreuses manières, que ce soit pour gagner en performance ou pour économiser de l’énergie. Dans cette section, nous allons voir quelles sont ces optimisations. ===Les caches pseudo-associatifs=== Les caches adressés par voie contiennent une mémoire SRAM par voie. En théorie, les voies sont accédées en parallèles, en même temps, afin de voir si l'on a un succès de cache ou un défaut. Les '''caches pseudo-associatifs''' sont identiques aux caches associatifs par voie, si ce n'est qu'ils vérifient chaque voie une par une. Ils ont été utilisés sur des processeurs commerciaux, un exemple étant l'IBM 370. Là encore, on perd en performance pour gagner en consommation d'énergie. Le temps d'accès dans le meilleur des cas est plus faible pour les caches pseudo-associatifs, mais le pire des cas teste tous les caches avant de tomber sur le bon. Les performances sont donc réduites. Mais la consommation énergétique est meilleure, vu qu'on ne vérifie pas forcément toutes les voies en parallèle. On teste la première voie, éventuellement la seconde, peut-être la troisième, etc. Mais dans le cas général, on ne teste qu'une partie des voies, pas toutes, ce qui donne un gain en termes d'énergie. L'implémentation de caches de ce genre demande que l'on parcoure les voies une par une, en commençant de la première jusqu'à la dernière. Pour cela, un simple compteur suffit. Suivant la valeur du compteur, la voie associée est activée puis accédée. Toute la complexité revient à ajouter un circuit qui prend la valeur du compteur, et active la voie associée, lance un accès mémoire dessus. Vu que les voies sont chacune des caches ''direct mapped'', il suffit pour cela de geler les entrées d'adresse, soit en les déconnectant, soit en utilisant du ''clock gating'' ou de l'évaluation gardée. Les détails d'implémentation, non-cités ici, varient selon le cache. ===La prédiction de voie=== Pour réduire le temps d'accès des caches pseudo-associatifs, certains chercheurs ont inventé la '''prédiction de voie''', qui consiste à faire des paris sur la prochaine voie accédée. L'idée est d'accéder à la voie qui contient la donnée voulue du premier coup, en lisant celle-ci en priorité. Dans son implémentation la plus simple, le cache reste un cache pseudo-associatif. Lors d'un accès au cache, les voies sont toutes parcoures une par une. Par contre, les voies ne sont donc pas parcourues de la première vers la dernière, mais dans un ordre différent. Cette technique permet de mettre en veille les voies sur lesquels le processeur n'a pas parié, ce qui permet de diminuer la consommation énergétique du processeur. C'est plus efficace que d'aller lire plusieurs données dans des voies différentes et de n'en garder qu'une. L'implémentation est assez simple : il suffit d'ajouter un circuit de prédiction de voie,relié au compteur de voie. Une amélioration de la technique fait fonctionner le cache comme un intermédiaire entre cache pseudo-associatif et associatif par voies. L'idée est de chercher la voie prédite en premier, puis de chercher dans toutes les voies en parallèle en cas de défaut de cache. Au lieu d'attendre que les comparaisons de tags donnent leur résultat, le processeur sélectionne automatiquement une voie et configure les multiplexeurs à l'avance. Si le processeur ne se trompe pas, le processeur accède à la donnée plus tôt que prévu. S'il se trompe, le processeur annule la lecture effectuée en avance et recommence en faisant un accès en parallèle aux autres voies. Le compromis entre performance et consommation d'énergie est alors différent. On économise de l'énergie par rapport à un cache associatif par voie, au prix d'une petite perte de performance (doublement des temps d'accès). Mais par rapport à un cache pseudo-associatif, l'économie d'énergie est bien moindre, au prix d'un gain en performance assez manifeste. Prédire quelle voie sera la bonne est assez simple. En vertu du principe de localité, les accès futurs ont des chances de tomber dans les voies les plus fréquemment utilisées ou dans celle plus récemment utilisée. Il suffit de retenir la voie la plus récemment accédée dans un registre, qui sera utilisée comme prédiction. Pour vérifier que la prédiction est correcte, il suffit de comparer le registre et le résultat obtenu après vérification des tags. Cependant, on peut complexifier l'implémentation pour prendre en compte l'adresse à lire/écrire, l'instruction à l'origine de l'accès mémoire ou tout autre paramètre utile. Par exemple, des instructions différentes ont tendance à aller chercher leurs données dans des ensembles différents et la voie à choisir n'est pas la même. Pour cela, il suffit d'utiliser un cache pour stocker la correspondance instruction - voie. Pour plus de simplicité, la mémoire cache des prédictions est parfois remplacée par une RAM, qui est adressée : * soit par le program counter de l'instruction à l'origine de l'accès (en réalité, seulement quelques bits de poids faible de l'adresse) ; * soit par l'adresse à accéder (là encore, quelques bits de poids faible) ; * soit (pour les modes d'adressage qui utilisent un registre de base et un décalage) par un XOR entre les bits de poids faible de l'adresse de base et le décalage ; * soit par autre chose. ===La mise en veille sélective des voies=== Les caches associatifs ont tendance à utiliser beaucoup d'énergie, même quand on n'y accède pas. Aussi, certains processeurs détectent quand le cache est peu utilisé et en profitent pour mettre en veille les voies inutilisées. Vous vous demandez certainement ce qui se passe quand une donnée à lire/écrire est dans une voie désactivée. La réponse est que le cache détecte cette situation, car elle déclenche un succès de cache. Les ''tags'' ne sont en effet pas désactivés, seules les données sont mises en veille. L'implémentation est plus simple sur les caches qui séparent les tags et les données dans deux RAM différentes. Cette optimisation marche surtout sur les gros caches, qui ont des chances d'avoir une portion significative d’inutilisée (pas assez de données pour les remplir), donc généralement les caches L3/L4. Par exemple, les processeurs d'Intel de microarchitecture Ivy Bridge disposent d'un cache de 8 mébioctets à 16 voies, qu'ils peuvent faire passer à 512 kibioctets si le besoin s'en fait sentir. Quand ces processeurs détectent une faible activité, ils mettent en veille 14 voies et n'en gardent que 2 d'actives. Évidemment, les 14 voies sont vidées avant d'être mises en veille, afin qu'une aucune donnée ne soit perdue. ===Les caches ''skew-associative''=== Vous aurez remarqué que dans une voie, les lignes sont accédées en adressage direct : les défauts par conflit sont possibles sur un cache associatif par voie. Pour éviter cela, certains chercheurs ont créé des '''caches ''skew associative''''' (ou associatifs à biais). Pour faire simple, les index des lignes de cache subissent un petit traitement avant d'être utilisés. Le traitement en question est différent suivant la voie de destination, histoire que deux adresses mémoires avec des index identiques donnent des index différents après traitement. Le traitement en question est souvent une permutation des bits de l'index, qui est différente suivant la voie prise, ou un simple XOR avec un nombre qui dépend de la voie. [[File:Implémentation d'un cache skew associative.jpg|centre|vignette|upright=2|Implémentation d'un cache skew associative.]] ==L'adressage physique ou logique des caches== Le cache utilise les adresses à lire/écrire pour déterminer s'il a une copie de la donnée en son sein. Mais l’interaction entre caches et mémoire virtuelle donne lieu à un petit problème : l'adresse utilisée est-elle une adresse virtuelle/logique ou physique ? La réponse varie suivant le processeur : certains caches utilisent l'adresse virtuelle, tandis que d'autres prennent l'adresse physique. On parle de cache '''virtuellement tagué''' dans le premier cas et de cache '''physiquement tagué''' dans le second. {| |[[File:Cache tagué virtuellement.png|vignette|Cache tagué virtuellement.]] |[[File:Cache tagué physiquement.png|vignette|Cache tagué physiquement.]] |} ===L'accès à un cache physiquement/virtuellement tagué=== La manière d'accéder à un cache dépend de s'il est virtuellement ou physiquement tagué. Il faut utiliser l'adresse virtuelle pour les premiers, physique pour les seconds. Avec un cache virtuellement tagué, l'adresse logique peut être envoyée directement au cache. La MMU ne traduit les adresses que s'il faut accéder à la mémoire RAM. Ces caches sont donc plus rapides. Avec un cache physiquement tagué, le processeur doit traduire l'adresse logique en adresse physique dans la MMU, avant d'accéder au cache. La traduction d'adresse se fait soit en accédant à une table des pages en mémoire RAM, soit en accédant à un cache spécifiquement dédié à accélérer la traduction d'adresse, la TLB (''Translation Lookaside Buffer''). Dans la quasi-totalité des cas, la traduction d'adresse passe par la TLB, ce qui fait qu'elle est raisonnablement rapide. Toujours est-il que chaque accès au cache demande d'accéder à la TLB et de faire la traduction d'adresse avant d'accéder au cache. L'accès est donc plus lent que sur les caches virtuellement tagués, où les accès sont plus directs. [[File:Virtual and Physical addressing.svg|centre|vignette|upright=2|Cache tagué virtuellement versus physiquement tagué.]] ===Les défauts des caches virtuellement tagués=== Les caches physiquement tagués sont moins rapides que les caches virtuellement adressés. Pourtant, les caches virtuellement tagués sont peu fréquents sur les processeurs modernes. Et la raison est assez intéressante : c'est une question d'adresses homonymes et synonymes. ====Les droits d'accès doivent être vérifiés lors d'un accès au cache==== Un premier problème est que la protection mémoire est compliquée avec de tels caches. Rappelons que certaines portions de mémoire sont accessibles seulement en lecture, ou sont interdites en écriture, sont inexécutables, etc. Ces droits d'accès sont gérés par la MMU, qui vérifie pour chaque accès mémoire que l'accès est autorisé. En bypassant la MMU, l'accès au cache virtuellement tagué ne permet pas de faire ces vérifications. Il est possible de charger une donnée en lecture seule dans le cache, mais d'y faire des accès en écriture pour les accès ultérieurs. Les solutions à cela sont multiples. La première consiste à consulter la MMU en parallèle de l'accès au cache. L'accès au cache est alors réalisé de manière spéculative, et est ensuite confirmé/annulé une fois que la MMU a rendu son verdict. Les performances du cache restent alors les mêmes : l'accès à la MMU se fait en parallèle de l'accès au cache, pas avant. Une autre solution est d'ajouter les droits d'accès en question dans la ligne de cache, dans les bits de contrôle situés après le Tag. Chaque accès au cache récupère ces bits de contrôle et vérifie si l'accès est autorisé. L'inconvénient est que les lignes de cache deviennent plus longues, les droits d'accès sont dupliqués entre MMU et cache. Mais si le budget en transistor suit, ce n'est rien d'insurmontable. ====Les adresses homonymes perturbent la gestion du cache==== Pour rappel, une adresse logique homonyme correspond à plusieurs adresses physiques différentes. Elles surviennent quand chaque programme a son propre espace d'adressage. Dans ce cas, une adresse logique correspondra à une adresse physique différente par programme.Une autre manière de voir les choses est qu'il y a en réalité deux adresses homonymes, qui ont la même valeur, mais appartiennent à des espaces d'adressage différentes. Et c'est cette seconde interprétation que nous allons utiliser. Les caches doivent gérer ces adresses homonymes et faire en sorte que la lecture/écriture d'une adresse homonyme se fasse à la bonne adresse physique, dans la bonne ligne de cache. Et autant un cache physiquement tagué n'a aucun problème avec ça, vu qu'il ne gère que des adresses physiques, autant des problèmes surviennent avec les caches virtuellement tagués. Le problème est que les caches virtuellement tagués doivent faire la différence entre deux adresses homonymes de même valeur. Pour corriger ces problèmes, il existe deux grandes méthodes. La première méthode est simple : '''vider les caches''' en changeant de programme. Leur contenu est rapatrié en mémoire RAM, puis les caches sont remis à zéro. Le vidage du cache recopie les lignes de cache ''dirty'' (modifiées) en RAM, puis efface/invalide tout le cache. C'est à cela que servent les instructions ''clean'' et d'invalidation vues plus haut, elles ont été inventées pour cette situation précise. Lorsque le système d'exploitation déclenche une commutation de contexte, à savoir qu'il change le programme en cours d'exécution, le processeur vide tous les caches du processeur. Les interruptions font la même chose, elles vide tous les caches du processeur. Une seconde méthode numérote chaque programme en cours d'exécution, chaque processus. Le numéro attribué est spécifique à chaque processus, ce qui fait qu'il est appelé un '''identifiant de processus CPU'''. Le processeur mémorise l'identifiant du programme en cours d'exécution dans un registre dédié. L'identifiant de processus CPU est utilisé lors des accès mémoire. Chaque ligne de cache contient le numéro de l'espace d'adressage associé, dans son ''tag''. Lors de chaque accès mémoire, l'ID du registre est comparé à l'ID de la ligne de cache accédée, pour vérifier que l'accès mémoire accède à la bonne donnée. Cette méthode n'est pas très économe en termes de transistors. L'usage d'identifiant de processus CPU est clairement meilleure en termes de performance, les commutations de contexte sont plus rapides. Par contre, le budget en transistor est plus important. Un autre défaut de cette méthode est que l'identifiant de processus est généralement codé sur une dizaine de bits, alors que le système d'exploitation utilise des identifiants de processus beaucoup plus larges, de 32 à 64 bits sur les CPU 32/64 bits. L'OS doit gérer la correspondance entre identifiants de processus CPU et ceux de l'OS. Parfois, pour cette raison, les OS n'utilisent pas toujours ce système d'identifiant de processus CPU. ====Les adresses synonymes perturbent aussi la gestion du cache==== La gestion des adresses synonymes est aussi un gros problème sur les caches virtuellement tagués. Pour rappel, il s'agit du cas où des adresses logiques différentes pointent vers la même adresse physique. Typiquement, quand deux programmes se partagent un morceau de mémoire, ce morceau correspondra à des adresses synonymes dans les deux espaces d'adressage. Mais il arrive que l'on ait des adresses synonymes dans le même espace d'adressage, ce n'est pas si rare ! Autant les adresses synonymes ne posent aucun problème avec les caches physiquement tagués, ce n'est pas le cas avec les caches virtuellement adressés. Sur ces caches, deux adresses logiques synonymes vont tomber dans deux lignes de cache différentes. Corriger ce problème demande d'ajouter des circuits annexes pour détecter les adresses synonymes, qui sont vraiment complexes et ont un cout en termes de performance. Aussi, les caches virtuellement tagués sont très peu utilisés sur les processeurs modernes. ===Les caches virtuellement adressés, mais physiquement tagués=== Si les caches physiquement et virtuellement tagués ont des défauts, il existe un intermédiaire qui est un bon compromis entre ces deux extrêmes. Il s'agit des '''caches virtuellement adressés - physiquement tagués''', aussi appelés '''caches pseudo-virtuels'''. Pour comprendre comment ils fonctionnent, précisons que ces caches sont soit des caches ''direct-mapped'', soit des caches associatifs par voie (composés de plusieurs RAM ''direct-mapped'' accédées en parallèle, plusieurs voies). L'accès à ce genre de cache se fait en deux temps : on accède à un ou plusieurs RAM ''direct-mapped'' et on vérifie ensuite les ''Tags'' pour sélectionner la bonne voie. Sur les caches ''direct-mapped'', on n'a qu'une seule RAM ''direct-mapped''. Sur les caches associatifs, on a plusieurs RAM ''direct-mapped'', appelées des voies, qui sont accédées en parallèle. L'accès se fait donc en deux étapes : adresser les RAM ''direct-mapped'' avec un indice, vérifier les ''tags'' avec le reste de l'adresse. Une autre chose à rappeler est que l'adresse logique est composée de deux parties : un numéro de page logique qui indique dans quel page se situe l'adresse, un décalage/''offset'' qui indique la position de l'adresse dans la page. La traduction d'adresse transforme le numéro de page logique en numéro de page physique, mais laisse le décalage intouché. L'idée est d'utiliser le décalage pour adresser les RAM avec le décalage, tandis que le numéro de page sert de ''tag''. Le décalage est découpé en deux lors de l'accès au cache : les bits de poids fort forment l'indice (l'adresse envoyée à la voie), les bits de poids faible donnent la position de l'adresse dans la ligne de cache. L'idée est d'utiliser un numéro de page physique pour les ''tags'', mais d'adresser les voies avec le décalage logique. Les deux servent à des instants différents : vérification des ''tags'' pour l'adresse physique, accès aux voies pour l'adresse logique. Ainsi, le problème des adresses synonymes ou homonymes est résolu par l'utilisation de l'adresse physique pour les tags. Par contre, l'accès au cache est plus rapide, car on utilise l'adresse logique pour la première étape. Le processeur accède à la TLB et récupère l'adresse physique pendant que l'on adresse les voies, les deux sont faits en parallèle, ce qui fait que tout se passe comme si l'accès à la TLB était gratuit. La TLB étant assez rapide comparé au cache, l'adresse physique est disponible quand on doit faire la comparaison avec les ''tags''. [[File:Virtual - Physical - Pseudo Virtual addressing.svg|centre|vignette|upright=2|Adressage pseudo virtuel des caches.]] Il s'agit d'un excellent compromis entre performance et correction des problèmes des adresses synonymes/homonymes. Tous les caches des processeurs haute performance utilisent cette méthode, au moins pour leurs caches L1. Les caches L2 tendent à utiliser des caches physiquement adressés, pour lesquels la latence d'accès est suffisante pour qu'on accède à la TLB en amont. La raison est assez simple à expliquer, elle provient d'une contrainte assez précise sur le calcul de l'indice. La conséquence est qu'un cache ''direct-mapped'' ne peut pas dépasser la taille d'une page, soit 4 kibioctets sur les ordinateurs actuels. Sur les caches associatifs, on peut dépasser cette limite en augmentant le nombre de voies, mais la taille maximale d'une voie reste celle d'une page. Cette contrainte n'est pas trop grave sur les caches de petite taille, dont les caches L1. La plupart d'entre eux ont trouvé un compromis idéal avec moins d'une dizaine de voies par cache, chacun de 4 kibioctets, ce qui donne des caches allant de 16 à 64 kibioctets, soit entre 4 et 16 voies. Par contre, un cache de grande taille doit utiliser un grand nombre de voies, ce qui est peu pratique. Aussi, cette technique de caches pseudo-virtuels n'est pas toujours appliquée sur les caches L2, qui sont physiquement adressés. Il faut dire qu'on accède au cache L2 lors d'un défaut dans le cache L1, et l'adresse physique est disponible à ce moment-là, elle a déjà été récupérée lors de l'accès au cache L1. On peut donc l'utiliser pour adresser le cache L2 sans perte de performance. ==Le remplacement des lignes de cache== Lorsqu'un cache est rempli et qu'on charge une nouvelle donnée dedans, il faut faire de la place pour cette dernière. Dans le cas d'un cache directement adressé, il n'y a rien à faire vu que la ligne de cache à évincer est déterminée lors de la conception du cache. Mais pour les autres caches, la donnée peut aller dans n'importe quelle ligne ou voie. Or, le choix des données à rapatrier en RAM doit être le plus judicieux possible : on doit virer de préférence des données inutiles. Rapatrier une donnée qui sera surement utilisée sous peu est inutile, et il vaudrait mieux supprimer des données qui ne serviront plus ou alors dans longtemps. Il existe différents algorithmes spécialement dédiés à résoudre ce problème efficacement, directement câblés dans les unités de gestion du cache. Certains sont vraiment très complexes, aussi je vais vous présenter quelques algorithmes particulièrement simples. Mais avant de voir ces algorithmes, il faut absolument que je vous parle d'une chose très importante. Quel que soit l'algorithme en question, il choisit la ligne de cache à évincer et recopie son contenu dans la RAM. Ce qui demande d'identifier et de sélectionner une ligne de cache parmi toutes les autres. Pour cela, le circuit de remplacement attribue une adresse chaque ligne de cache ! Vous avez bien vu : chaque ligne de cache est numérotée par une adresse, interne au cache. ===Le remplacement aléatoire=== Premier algorithme : la donnée effacée du cache est choisie au hasard ! C'est contre-intuitif, mais cet algorithme donne des résultats assez honorables, en plus d'utiliser très peu de portes logiques (un générateur de nombres pseudo-aléatoire est un circuit assez simple). Généralement, les défauts de cache sont séparés par un nombre assez important et irrégulier de cycles d'horloge. Dans ces conditions, cette technique donne un bon résultat. ===FIFO : first in, first out=== Avec l'algorithme FIFO, la donnée effacée du cache est la plus ancienne, celle chargée dans le cache avant les autres. Cet algorithme est très simple à implémenter en circuit, concevoir une mémoire de type FIFO n'étant pas très compliqué, comme on l’a vu dans le chapitre dédié à ce type de mémoires. Et on peut dire que dans le cas d'un cache, l'implémentation est encore plus simple et se contente d'un seul registre/compteur. Typiquement, il suffit d'ajouter un registre qui mémorise où se situe la donnée la plus récente. Toute insertion d'une nouvelle donnée se fait à l'adresse suivante, ce qui demande juste d'incrémenter le registre avant d'utiliser son contenu pour l'accès mémoire. [[File:Algorithme FIFO de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme FIFO de remplacement des lignes de cache.]] Cet algorithme possède une petite particularité sur les caches associatifs par voie : en augmentant le nombre d'ensembles, les performances peuvent se dégrader : c'est ce qu'on appelle l''''anomalie de Bélády'''. ===MRU : most recently used=== Avec l'algorithme MRU, la donnée remplacée est celle qui a été utilisée le plus récemment. Cet algorithme s'implémente simplement avec un registre, dans lequel on place le numéro de la dernière ligne de cache utilisée. Cet algorithme de remplacement est très utile quand un programme traverse des tableaux du premier élément jusqu'au dernier : les données du tableau sont rarement réutilisées, rendant le cache inutile. Il est prouvé que dans ces conditions, l'algorithme MRU est optimal. Mais dans toutes les autres conditions, cet algorithme a des performances assez misérables. ===LFU : least frequently used=== Avec l'algorithme LFU, la donnée supprimée est celle qui est utilisée le moins fréquemment. Cet algorithme s'implémente en associant un compteur à chaque ligne de cache, qui est incrémenté à chaque accès mémoire. La ligne la moins récemment utilisée est celle dont le compteur associé a la plus petite valeur. Implémenter cet algorithme prend pas mal de transistors, car il faut rajouter autant de compteurs qu'il y a de lignes de cache, en plus d'un circuit pour comparer les compteurs et d'un encodeur. [[File:Algorithme LFU de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme LFU de remplacement des lignes de cache]] ===LRU : least recently used=== Avec l'algorithme LRU, la donnée remplacée est celle qui a été utilisée le moins récemment. Cet algorithme se base sur le principe de localité temporelle, qui stipule qu'une donnée accédée récemment a de fortes chances d'être réutilisée dans un futur proche. Et inversement, la donnée la moins récemment utilisée du cache est celle qui a le plus de chance de ne servir à rien dans le futur. Autant la supprimer en priorité pour faire de la place à des données potentiellement utiles. Implémenter l'algorithme LRU peut se faire de différentes manières, qui ont pour point commun d'enregistrer les accès au cache pour en déduire la ligne la moins récemment accédée. La manière la plus simple demande d'utiliser un compteur pour chaque ligne de mémoire cache, un peu comme le LFU. La différence avec le LFU est que le compteur n'est pas incrémenté lors d'un accès mémoire. À la place, ce compteur est incrémenté régulièrement, chaque incrémentation ayant lieu en même temps pour tous les compteurs. Quand un bloc est chargé dans le cache, ce compteur est mis à zéro. Quand une ligne de cache doit être remplacée, un circuit va vérifier la valeur de tous les compteurs : la ligne LRU (la moins récemment utilisée), est celle dont le compteur a la valeur la plus haute. Le circuit est composé d'un paquet de comparateurs, et d'un encodeur, comme pour l'agorithme LFU. ===Les approximations du LRU=== Implémenter le LRU demande un nombre de transistors proportionnel au carré du nombre de lignes de cache. Autant dire que le LRU devient impraticable sur de gros caches. Ce qui fait que les processeurs modernes implémentent des variantes du LRU, moins couteuses en transistors, qui donnent un résultat approximativement semblable au LRU. En clair, ils ne sélectionnent pas toujours la ligne de cache la moins récemment utilisée, mais une ligne de cache parmi les moins récemment utilisées. Ce n'est pas un problème si grave que cela car les lignes les moins récemment utilisées ont toutes assez peu de chance d'être utilisées dans le futur. Entre choisir de remplacer une ligne qui a 0,5 % de chances d'être utilisée dans le futur et une autre qui a une chance de seulement 1 %, la différence est négligeable en termes de taux de succès. Mais les gains en termes de circuits ou de temps d'accès au cache de ces algorithmes sont très intéressants. L'algorithme le plus simple consiste à couper le cache (ou chaque voie s'il est associatif) en plusieurs sections. L'algorithme détermine la section la moins récemment utilisée, avant de choisir aléatoirement une ligne de cache dans cette section. Pour implémenter cet algorithme, il nous suffit d'un registre qui mémorise le morceau le moins récemment utilisé, et d'un circuit qui choisit aléatoirement une ligne de cache. Cette technique s'adapte particulièrement bien avec des caches associatifs à voies : il suffit d'utiliser autant de morceaux que de voies. Autre algorithme, un peu plus efficace : le '''pseudo-LRU de type M'''. Cet algorithme attribue un bit à chaque ligne de cache, bit qui sert à indiquer de façon approximative si la ligne de cache associée est une candidate pour un remplacement ou non. Il vaut 1 si la ligne n'est pas une candidate pour un remplacement et zéro sinon. Le bit est mis à 1 lorsque la ligne de cache associée est lue ou écrite. Évidemment, au fil du temps, toutes les lignes du cache finiront par avoir leur bit à 1. Lorsque cela arrive, l'algorithme remet tous les bits à zéro, sauf pour la dernière ligne de cache accédée. L'idée derrière cet algorithme est d'encercler la ligne de cache la moins récemment utilisée au fur et à mesure des accès. L'encerclement commence lorsque l'on remet tous les bits associés aux lignes de cache à 0, sauf pour la ligne accédée en dernier. Au fur et à mesure des accès, l'étau se resserre autour de la ligne de cache la moins récemment utilisée. Après un nombre suffisant d'accès, l'algorithme donne une estimation particulièrement fiable. Et comme les remplacements de lignes de cache sont rares comparés aux accès aux lignes, cet algorithme finit par donner une bonne estimation avant qu'on ait besoin d'effectuer un remplacement. Le dernier algorithme d'approximation, le '''PLURt''', se base sur ce qu'on appelle un arbre de décision. Il a besoin de n − 1 bits pour déterminer la ligne LRU. Ces bits doivent être organisés en arbre, comme illustré plus bas. Chacun de ces bits sert à dire : le LRU est à ma droite ou à ma gauche : il est à gauche si je vaux 0, et à droite si je vaux 1. Trouver le LRU se fait en traversant cet arbre, et en interprétant les bits un par un. Au fur et à mesure des lectures, les bits sont mis à jour dans cet arbre, et pointent plus ou moins bien sur le LRU. La mise à jour des bits s'effectue lors des lectures et écritures : quand une ligne est lue ou écrite, elle n'est pas la ligne LRU. Pour l'indiquer, les bits à 1 qui pointent vers la ligne de cache sont mis à 0 lors de la lecture ou écriture. {| |[[File:Organisation des bits avec l'algorithme PLURt.jpg|vignette|Organisation des bits avec l'algorithme PLURt.]] |[[File:Ligne de cache pointée par les bits de l'algorithme.png|vignette|Ligne de cache pointée par les bits de l'algorithme.]] |} ===LRU amélioré=== L'algorithme LRU, ainsi que ses variantes approximatives, sont très efficaces tant que le programme respecte relativement bien la localité temporelle. Par contre, Le LRU se comporte assez mal dans les circonstances ou la localité temporelle est mauvaise mais où la localité spatiale est respectée, le cas le plus emblématique étant le parcours d'un tableau. Pour résoudre ce problème, des variantes du LRU existent. Une variante très connue, l''''algorithme 2Q''', utilise deux caches : un cache FIFO pour les données accédées une seule fois et un second cache LRU. Évidemment, les données lues une seconde fois sont migrées du cache FIFO vers le cache LRU, ce qui n'est pas très pratique. Les processeurs n'utilisent donc pas cette technique, mais celle-ci est utilisée dans les caches de disque dur. D'autres variantes du LRU combinent plusieurs algorithmes à la fois et vont choisir lequel de ces algorithmes est le plus adapté à la situation. Notre cache pourra ainsi détecter s’il vaut mieux utiliser du MRU, du LRU, ou du LFU suivant la situation. ==Les écritures dans le cache : gestion et optimisations== Les écritures se font à une adresse mémoire bien précise, qui peut ou non être chargée dans le cache. Si la donnée à écrire est chargée dans le cache, elle est modifiée directement dans le cache, mais elle ne l'est pas forcément en mémoire RAM. Suivant le processeur, les écritures sont ou non propagées en mémoire RAM. Il existe deux stratégies d'écritures, appelées respectivement le ''write-back'' et le ''write-through''. Avec un cache ''write-back'', si la donnée à mettre à jour est présente dans le cache, on écrit dans celui-ci sans écrire dans la mémoire RAM. Dans ces conditions, une donnée n'est enregistrée en mémoire que si celle-ci quitte le cache, ce qui évite de nombreuses écritures mémoires inutiles. [[File:Cache write-through.png|centre|vignette|upright=2|Cache write-through.]] Avec les caches '''Write-Through''', toute écriture dans le cache est propagée en RAM. Cette stratégie augmente le nombre d'écritures dans la mémoire RAM, ce qui peut saturer le bus reliant le processeur à la mémoire. Les performances de ces caches sont donc légèrement moins bonnes que pour les caches ''write back''. Par contre, ils sont utiles dans les architectures avec plusieurs processeurs, comme nous le verrons dans les chapitres sur les architectures multiprocesseurs. [[File:Cache write-back.png|centre|vignette|upright=2|Cache write-back.]] ===Les caches ''Write-through''=== Sans optimisation particulière, on ne peut écrire dans un cache ''write-through'' pendant qu'une écriture en RAM a lieu en même temps : cela forcerait à effectuer deux écritures simultanées, en comptant celle imposée par l'écriture dans le cache. Pour éviter cela, certains caches ''write-through'' intègrent un '''tampon d’écriture''', qui sert de file d'attente pour les écritures en RAM. C'est une mémoire FIFO dans laquelle on place temporairement les données à écrire en RAM, où elles attendent en attendant que la RAM soit libre. Grâce à lui, le processeur peut écrire dans un cache même si d'autres écritures sont en attente dans le tampon d'écriture. Par souci d'efficacité, des écritures à la même adresse en attente dans le tampon d’écriture sont fusionnées en une seule. Cela fait un peu de place dans le tampon d’écriture, et lui permet d'accumuler plus d'écritures avant de devoir bloquer le cache. Il est aussi possible de fusionner des écritures à adresses consécutives de la mémoire en une seule écriture en rafales. Dans les deux cas, on parle de '''combinaison d'écriture'''. Mais la technique du tampon d'écriture a cependant un léger défaut qui se manifeste dans une situation bien précise : quand le processeur veut lire une donnée en attente dans le tampon d’écriture. La première manière de gérer cette situation est de mettre en attente la lecture tant que la donnée n'a pas été écrite en mémoire RAM. On peut aussi lire la donnée directement dans le tampon d'écriture, cette optimisation portant le nom de '''''store-to-load forwading'''''. Dans tous les cas, il faut détecter le cas où une lecture accède à une donnée dans le tampon d'écriture. À chaque lecture, l'adresse à lire est envoyée au tampon d'écriture, qui vérifie si une écriture en attente se fait à cette adresse. Pour cela, le tampon d’écriture doit être un cache, dont chaque entrée mémorise une écriture. Chaque ligne de cache contient la donnée à écrire, et le tag de la ligne de cache contient l'adresse où écrire la donnée. Notons que cache d'écriture a une politique de remplacement de type FIFO, le tampon d'écriture non-optimisé étant une mémoire FIFO. ===Les caches ''Write-back''=== Les caches ''write-back'' ont beau avoir des performances supérieures à celles des caches ''write-through'', il existe des optimisations qui permettent d'améliorer leurs performances. Ces optimisations consistent à ajouter des caches spécialisés à côté du cache proprement dit. Ces caches permettent de mémoriser des données qui sont éliminées du cache par les algorithmes de remplacement de ligne cache, sans pour autant faire une écriture en RAM. En suivant la procédure habituelle de remplacement des lignes de cache, on doit rapatrier la ligne en RAM avant d'en charger une nouvelle. On peut améliorer la situation en faisant l'inverse : on charge la nouvelle ligne pendant que l'ancienne donnée est rapatriée en RAM. Ainsi, la nouvelle ligne est disponible plus tôt pour le processeur, diminuant son temps d'attente. Pour implémenter cette technique, on doit mémoriser l'ancienne ligne de cache temporairement dans un '''cache d’éviction''' (ou ''write-back buffer''). [[File:Cache d’éviction.png|centre|vignette|upright=2|Cache d’éviction]] Les caches directement adressés ou associatifs par voie possèdent aussi un tampon d’écriture amélioré. Pour limiter les défauts par conflit de ces caches, des scientifiques ont eu l'idée d'insérer un cache pour stocker les données virées du cache. En faisant ainsi, si une donnée est virée du cache, on peut alors la retrouver dans ce cache spécialisé. Ce cache s'appelle le '''cache de victime'''. Ce cache de victime est géré par un algorithme de suppression des lignes de cache de type FIFO. Petit détail : ce cache utilise un tag légèrement plus long que celui du cache directement adressé au-dessus de lui. L'index de la ligne de cache doit en effet être contenu dans le tag du cache de victime, pour bien distinguer deux adresses différentes, qui iraient dans la même ligne du cache juste au-dessus. [[File:Victim Cache Implementation Example.svg|centre|vignette|upright=1|Cache de victime.]] ===La configuration du fonctionnement du cache=== Sur de nombreux processeurs, il est possible de configurer la mémoire cache pour qu'elle fonctionne soit en mode ''write-back'', soit en mode ''write-through''. Pour cela, les processeurs modernes incorporent des '''registres de configuration du cache'''. Le terme ''registre de configuration du cache'' est assez transparent et indique bien quel est leur rôle. Ils configurent comment le cache est utilisé et permettent notamment de configurer le cache pour dire s'il doit fonctionner en mode ''write-back'' ou ''write-through''. Ils permettent aussi d'activer ou de désactiver la combinaison sur écriture. Les registres en question sont configurés soit par le BIOS, soit par le système d'exploitation. Ce sont des registres protégés, que les applications ne peuvent pas configurer, elles n'en ont pas le droit. Typiquement, ils ne sont accessibles en écriture qu'en mode noyau. Sur les processeurs x86, les registres de configuration du cache sont appelés des '''''Memory type range registers''''' (''MTRRs''). Les MTRRs sont assez nombreux, et il y a notamment une différence entre mode réel et protégé. Si vous vous souvenez des chapitres sur le mode d'adressage et la mémoire virtuelle, vous vous souvenez que les processeurs x86 incorporent plusieurs modes de fonctionnement. En mode réel, le processeur ne peut adresser qu'un mébioctet de RAM, avec un système de segmentation particulier. En mode protégé, le processeur peut adresser toute la mémoire et la segmentation fonctionne différemment, quand elle n'est pas simplement désactivée. Les MTRRs sont séparés en deux : ceux pour le mode réel, ceux pour le mode protégé. Les MTRRs fixes sont ceux qui configurent le cache en mode réel, ils étaient utilisés pour gérer l'accès au BIOS, à la mémoire VGA de la carte graphique, et quelques autres accès aux entrées-sorties basiques gérées nativement par le BIOS. Pour le mode protégé, les processeurs au-delà du 386 incorporent des MTRRs variables, qui servent pour les autres entrées-sorties en général, notamment les périphériques PCI, la mémoire vidéo de la carte graphique, et j'en passe. De nos jours, les registres de configuration du cache sont désuets et cette fonctionnalité est gérée directement par la mémoire virtuelle. La table des pages contient, pour chaque page mémoire, des bits de contrôle qui disent si la page mémoire est cacheable ou non. Le contournement de cache est alors géré par le système de mémoire virtuelle, le cache de TLB et tout ce qui va avec. ===L’allocation sur écriture=== Que faire quand une écriture modifie une donnée qui n'est pas dans le cache ? Doit-on écrire la donnée dans le cache, ou non ? Si la donnée est écrite dans le cache, on dit que le cache fait une '''allocation sur l'écriture''' (ou ''write-allocate''). Certains caches effectuent une telle allocation sur écriture, mais d'autres ne le font pas ou du moins pas systématiquement. L’allocation sur écriture peut se décliner en deux sous-catégories : le '''chargement à la demande''' et l''''écriture immédiate'''. Dans le premier cas, on charge la donnée à modifier dans le cache, et on la remplace avec la donnée écrite. Dans l'écriture immédiate, l'écriture a lieu directement dans le cache et la donnée à modifier n'est pas chargée dans le cache. Évidemment, seule une portion de la ligne de cache contient la donnée écrite (valide), et le reste contient des données invalides. Le cache doit savoir quelles sont les portions du cache qui sont valides : cela demande d'utiliser un ''sector cache''. [[File:Write-back with write-allocation.svg|centre|vignette|upright=2|Cache Write-back avec allocation sur écriture.]] Sans allocation sur écriture, l'écriture est transférée directement aux niveaux de cache inférieurs ou à la mémoire si la donnée à modifier n'est pas dans le cache. Certains caches de ce genre utilisent une petite optimisation : lors de toute écriture, ils supposent que l'écriture donnera un succès de cache. Si c'est le cas, la ligne de cache qui contient la donnée est mise à jour avec la donnée à écrire. Mais si ce n'est pas le cas, la ligne de cache est invalidée, et l'écriture est transférée directement à la mémoire ou aux niveaux de cache inférieurs. [[File:Write-through with no-write-allocation.svg|centre|vignette|upright=2|Cache Write-through sans allocation sur écriture.]] ===La cohérence des caches=== Il arrive parfois que la mémoire d'un ordinateur soit mise à jour, sans que les modifications soient répercutées dans les mémoires cache. Dans ce cas, le cache contient une donnée périmée. Or, un processeur doit toujours éviter de se retrouver avec une donnée périmée et doit toujours avoir la valeur correcte dans ses caches : cela s'appelle la '''cohérence des caches'''. Il est possible de se retrouver avec des valeurs périmées dans le cache sur les ordinateurs avec plusieurs processeurs, ou si un périphérique écrit en RAM, les modifications ne sont pas répercutées automatiquement dans les mémoires cache. Pour résoudre ce problème, on peut interdire de charger dans le cache des données stockées dans les zones de la mémoire dédiées aux périphériques. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires cache. Autre solution : utiliser le fait que les périphériques déclenchent une interruption matérielle pour laisser le contrôleur DMA accéder à la mémoire. Dans ce cas, il suffit de vider les caches à chaque interruption matérielle. Le processeur peut le faire automatiquement, ou fournir des instructions pour. ==Le ''cache bypassing'' : contourner le cache== Dans certaines situations, le cache n'est pas utilisé pour certains accès mémoire. Diverses techniques permettent en effet d'effectuer des accès mémoire qui contournent le cache, qui ne passent pas par le cache. Ils sont utilisés quand l'accès en cache fait que des instructions normales ne fonctionnent pas. Par exemple, de tels accès directs à la RAM sont notamment utilisés pour l'implémentation d'instructions atomiques, une classe d'instructions spécifiques utilisées sur les processeurs multicœurs, dont nous parlerons dans plusieurs chapitres. Mais ils sont aussi utilisés pour l'accès aux périphériques, ce que nous allons voir maintenant. ===Accéder aux périphériques demande de contourner le cache=== Pour rappel, un périphérique (au sens d'entrée-sortie) contient des registres d’interfaçage qui ont une adresse au même titre que les cases mémoire. Un périphérique peut à tout instant modifier ses registres d’interfaçage, ce qui se répercute automatiquement dans l'espace d'adressage, mais rien de tout cela n'est transmis au cache. Si les accès aux périphériques passaient par l'intermédiaire du cache, on aurait droit à des problèmes. On aurait encore une fois droit à des problèmes de cohérence des caches. Le problème est géré différemment suivant que l'on utilise un espace d'adressage séparé ou des entrées-sorties mappées en mémoire. La solution est que les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache. Cela demande d'adapter le cache et le processeur. L'implémentation exacte dépend de comment sont adressés les périphériques. Pour rappel, il y a deux solutions pour adresser les périphériques : soit les périphériques disposent d'un espace d'adressage séparé de celui de la mémoire, soit il y un espace d'adressage unique partagé entre processeur et mémoire. Les deux cas donnent des solutions différentes. Avec un espace d'adressage séparé, l'espace d'adressage des périphériques n'est pas caché : aucun accès dans cet espace d'adressage ne passe par le cache. La mémoire cache n'est utilisée que pour l'espace d'adressage des mémoires, rien d'autre. C'est de loin le cas le plus simple : il suffit de concevoir le processeur pour. Il dispose d'instructions séparées pour les accès aux registres d’interfaçage et à la RAM/ROM, les premières ne passent pas par le cache, les autres si. Avec des entrées-sorties mappées en mémoire, la même solution est utilisée, mais dans une version un peu différente. Là encore, les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache, si on veut qu'ils marchent comme ils le doivent. Cela demande d'adapter le cache et le matériel pour que accès aux périphériques mappés en mémoire contournent le cache. Des adresses, voire des zones entières de la mémoire, sont marquées comme étant non-cachables. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires caches. Là encore, le processeur doit être prévu pour : on doit pouvoir le configurer de manière à marquer certaines zones de la RAM comme non-cacheable. Reste qu'il faut marquer des régions de la RAM comme non-cacheable. Pour cela, on améliore les registres de configuration du cache, vus plus haut, afin qu'ils permettent de configurer certaines portions de la RAM pour préciser qu'elles ne doivent pas être mises en cache, qu'il faut activer le contournement de cache pour celles-ci. ===Contourner le cache pour des raisons de performance=== Il arrive que des données avec une faible localité soient chargées dans le cache inutilement. Or, il vaut mieux que ces données transitent directement entre le processeur et la mémoire, sans passer par l'intermédiaire du cache. Pour cela, le processeur peut fournir des instructions d'accès mémoire qui ne passent pas par le cache, à côté d'instructions normales. De telle instructions sont appelées des '''instructions mémoire non-temporelles'''. Non-temporelle, dans le sens : pas de localité temporelle (c.a.d que les données ne seront pas réutilisées plus tard). Mais il existe aussi des techniques matérielles, où le cache détecte à l'exécution les lectures qui gagnent à contourner le cache. La dernière méthode demande d'identifier les instructions à l'origine des défauts de cache, le processeur accédant directement à la RAM quand une telle instruction est détectée. Si une instruction d'accès mémoire fait trop de défauts de cache, c'est signe qu'elle gagne à contourner le cache. L'idée est de mémoriser, pour chaque instruction d'accès mémoire, un historique de ses défauts de cache. Il existe plusieurs méthodes pour cela, mais toutes demandent d'ajouter de quoi mémoriser l'historique des défauts de cache des instructions. L'historique est mémorisé dans une mémoire appelée la '''table d’historique des défauts de lecture''' (''load miss history table''), qui est souvent un cache. L'historique en question est, dans sa version la plus simple, un compteur de quelques bits incrémenté à chaque succès de cache et décrémenté à chaque défaut de cache, qui indique si l'instruction a en moyenne fait plus de défauts ou de succès de cache. La table associe le ''program counter'' d'une instruction mémoire à cet historique. À la première exécution d'une instruction d'accès mémoire, une entrée de cette table est réservée pour l'instruction. Lors des accès ultérieurs, le processeur récupérer les informations associées et décide s'il faut contourner le cache ou non. ==La hiérarchie mémoire des caches== [[File:Cache Hierarchy.png|vignette|Hiérarchie de caches]] On pourrait croire qu'un seul cache est largement suffisant pour compenser la lenteur de la mémoire. Hélas, les processeurs sont devenus tellement rapides que les caches sont eux-mêmes très lents ! Pour rappel, plus une mémoire peut contenir de données, plus elle est lente. Et les caches ne sont pas épargnés. Si on devait utiliser un seul cache, celui-ci serait très gros et donc trop lent. La situation qu'on cherche à éviter avec la mémoire RAM revient de plus belle. Même problème, même solution : si on a décidé de diviser la mémoire principale en plusieurs mémoires de taille et de vitesse différentes, on peut bien faire la même chose avec la mémoire cache. Depuis environ une vingtaine d'années, un processeur contient plusieurs caches de capacités très différentes : les caches L1, L2 et parfois un cache L3. Certains de ces caches sont petits, mais très rapides : c'est ceux auxquels on va accéder en priorité. Viennent ensuite d'autres caches, de taille variable, mais plus lents. Les processeurs ont donc une hiérarchie de caches qui se fait de plus en plus complexe avec le temps. Cette hiérarchie est composée de plusieurs niveaux de cache, qui vont des niveaux inférieurs proches de la mémoire RAM à des niveaux supérieurs proches du processeur. Plus on monte vers les niveaux supérieurs, plus les caches sont petits et rapides. Un accès mémoire dans une hiérarchie de cache fonctionne comme suit : on commence par vérifier si la donnée recherchée est dans le cache le plus rapide, à savoir le cache L1. Si c'est le cas,n on la charge depuis ce cache directement. Si elle n’y est pas, on vérifie si elle est dans le cache de niveau supérieur, le cache L2. Et rebelote ! Si elle n'y est pas, on vérifie le cache du niveau supérieur. Et on répète cette opération, jusqu’à avoir vérifié tous les caches. Si la donnée n'est dans aucun cache, on doit alors aller chercher la donnée en mémoire. [[File:Hiérarchie de caches.png|centre|vignette|upright=2|Hiérarchie de caches]] Il y a des différences assez notables entre chaque niveau de cache. Par exemple, les différents niveaux de cache n'ont pas forcément les mêmes politiques de remplacement des lignes de cache. Le cache L1 a généralement une politique de remplacement simple, très rapide, mais peu efficace. De même, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Le cache le plus proche de la mémoire est appelé le '''cache de dernier niveau''', ''Last Level Cache'' en anglais. Il a parfois des caractéristiques totalement différentes des autres caches. Par exemple, sur les processeurs multicoeurs, le cache L3 n'est techniquement pas dans le cœur, mais fait partie d'un ensemble de circuits reliés, comme le contrôleur mémoire ou l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc. ===Les caches exclusifs et inclusifs=== Notons que du point de vue de cette vérification, il faut distinguer les caches inclusifs et exclusifs. Avec les caches inclusifs, si une donnée est présente dans un cache, alors elle est présente dans les caches des niveaux inférieurs, ce qui implique l'existence de données en doublon dans plusieurs niveaux de cache. À l'opposé, les caches exclusifs font que toute donnée est présente dans un seul cache, pas les autres. Il existe aussi des caches qui ne sont ni inclusifs, ni exclusifs. Sur ces caches, chaque niveau de cache gère lui-même ses données, sans se préoccuper du contenu des autres caches. Pas besoin de mettre à jour les niveaux de cache antérieurs en cas de mise à jour de son contenu, ou en cas d'éviction d'une ligne de cache. La conception de tels caches est bien plus simple. Dans les '''caches exclusifs''', le contenu d'un cache n'est pas recopié dans le cache de niveau inférieur. Il n'y a pas de donnée en double et on utilise 100 % de la capacité du cache, ce qui améliore le taux de succès. Par contre, le temps d'accès est un peu plus long. La raison est que si une donnée n'est pas dans le cache L1, on doit vérifier l'intégralité du cache L2, puis du cache L3. De plus, assurer qu'une donnée n'est présente que dans un seul cache nécessite aux différents niveaux de caches de communiquer entre eux pour garantir que l'on a pas de copies en trop d'une ligne de cache, ce qui peut prendre du temps. [[File:Caches exclusifs.png|centre|vignette|upright=2|Caches exclusifs]] Dans le cas des '''caches inclusifs''', le contenu d'un cache est recopié dans les caches de niveau inférieur. Par exemple, le cache L1 est recopié dans le cache L2 et éventuellement dans le cache L3. Ce genre de cache a un avantage : le temps d'accès à une donnée est plus faible. La raison est qu'il ne faut pas vérifier tout un cache, mais seulement la partie qui ne contient pas de donnée en doublon. Par exemple, si la donnée voulue n'est pas dans le cache L1, on n'est pas obligé de vérifier la partie du cache L2 qui contient la copie du L1. Ainsi, pas besoin de vérifier certaines portions du cache, ce qui est plus rapide et permet de simplifier les circuits de vérification. En contrepartie, l'inclusion fait que qu'une partie du cache contient des copies inutiles, comme si le cache était plus petit. De plus, maintenir l'inclusion est compliqué et demande des circuits en plus et/ou des échanges de données entre caches. [[File:Caches inclusifs.png|centre|vignette|upright=2|Caches inclusifs]] Maintenir l'inclusion demande de respecter des contraintes assez fortes, ce qui ne se fait pas facilement. Premièrement, toute donnée chargée dans un cache doit aussi l'être dans les caches de niveau inférieur. Ensuite, quand une donnée est présente dans un cache, elle doit être maintenue dans les niveaux de cache inférieurs. De plus, toute donnée effacée d'un cache doit être effacée des niveaux de cache supérieurs : si une donnée quitte le cache L2, elle doit être effacée du L1. Ces trois contraintes posent des problèmes si chaque cache décide du remplacement des lignes de cache en utilisant un algorithme comme LRU, LFU, MRU, ou autre, qui utilise l'historique des accès. En effet, dans ce cas, le cache décide de remplacer les lignes de cache selon l'historique des accès, historique qui varie suivant chaque niveau de cache. Par exemple, une donnée rarement utilisée dans le L2 peut parfaitement être très fréquemment utilisée dans le L1 : la donnée sera alors remplacée dans le L2, mais sera maintenue dans le L1. On observe aussi des problèmes quand il existe plusieurs caches à un seul niveau : chaque cache peut remplacer les lignes de cache d'une manière indépendante des autres caches du même niveau, donnant lieu au même type de problème. Pour maintenir l'inclusion, les caches doivent se transmettre des informations qui permettent de maintenir l'inclusion. Par exemple, les caches de niveaux inférieurs doivent prévenir les niveaux de cache supérieurs quand ils remplacent une ligne de cache. De plus, toute mise à jour dans un cache doit être répercutée dans les niveaux de cache inférieurs et/ou supérieurs. On doit donc transférer des informations de mise à jour entre les différents niveaux de cache. Généralement, le contenu des caches d'instruction n'est pas inclus dans les caches de niveau inférieurs, afin d'éviter que les instructions et les données se marchent sur les pieds. Enfin, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Dans ce cas, l'inclusion est plus difficile à maintenir, pour des raisons assez techniques. ===Les caches eDRAM, sur la carte mère et autres=== D'ordinaire, les mémoires caches sont intégrées au processeur, à savoir que cache et CPU sont dans le même circuit imprimé. Les caches sont donc fabriqués avec de la SRAM, seule forme de mémoire qu'on peut implémenter dans un circuit intégré. Intégrer tous les caches dans le processeur est une solution et efficace. Mais certains processeurs ont procédé autrement. [[File:Cache-on-a-stick module.jpg|vignette|Cache-on-a-stick module]] Des processeurs assez anciens incorporaient un cache L1 dans le processeur, mais plaçaient un cache L2 sur la carte mère. Le cache était clippé sur un connecteur sur la carte mère, un peu comme le sont les barrettes de mémoire. On parlait alors de '''''Cache on a stick''''' (COAST). On aurait pu s'attendre à ce que de tels caches soient en DRAM, vu qu'ils sont placés sur des barrettes de RAM, mais la ressemblance avec la mémoire RAM principale s'arrête là. Le cache était fabriqué en mémoire SRAM, même s'il est en théorie possible de faire de tels caches avec de la DRAM. Les premiers processeurs avec un cache faisaient ainsi, au début des années 90. Il a été introduiot sur les processeurs Motorola, et a été utilisé sur les IBM PC et les Macintosh de l'époque. Les ordinateurs Macintosh utilisaient de tels caches, pour la pluaprt des modèles. Pour ce qui est des PC, les premiers processeurs x86 faisaient pareil, notamment les processeurs Intel. Le 486, le Pentium et le Pentium 2 utilisaient des ''Cache on a stick''. L'avantage est que cela permettait de mettre plus de cache, à une époque où les circuits étaient limités en transistors. De plus, cela permettait au consommateur de choisir quelle quantité de cache il voulait, selon ses finances. Il était possible de laisser le processeur fonctionner soit sans mémoire cache, soit avec un cache de 256 Kibioctets, de 512 Kibioctets, etc. Il était possible d'upgrader le cache si besoin. Pour les CPU Intel, le cache était connecté sur le bus système, au même titre que la mémoire RAM et les entrées-sorties. Il faut dire que les processeurs de l'époque utilisaient un bus système et n'avaient pas de bus mémoire dédié. Mais en théorie, rien n’empêche de connecter le cache sur un bus mémoire dédié. Toujours est-il que les lectures et écritures étaient propagées à la fois dans le cache et la RAM. Les écritures se faisaient dans les deux, systématiquement dans la RAM, mais aussi dans le cache en cas de succès de cache. Les lectures étaient servies soit par le cache en cas de succès de cache, soit par la RAM en cas de défaut de cache. Si le cache répondait en premier, la transaction sur le bus se terminait précocement et l'accès en RAM était abandonné. [[File:Intel486 Иерархия памяти.png|centre|vignette|upright=2.5|Intel486 : le cache était connecté sur le bus système.]] À l'inverse, certains processeurs possédaient un cache fabriqué en mémoire DRAM, et plus précisément avec de la mémoire eDRAM. Le cache n'était pas intégré dans le même circuit imprimé que le processeur, mais profitait d'une architecture en ''chiplet''. Pour rappel, cela veut dire que le processeur est en réalité composé de plusieurs circuits intégré séparés, mais interconnectés et soudés sur un même PCB carré. Avec un cache en eDRAM, le cache avait son propre circuit intégré, séparé du circuit intégré du processeur ou du circuit intégré pour le contrôleur mémoire/IO. Un exemple est celui du cache des processeurs Intel de microarchitecture Broadwell, que nous allons voir dans la section suivante. ==Les caches splittés (''phased caches'')== Dans cette section, nous allons voir les '''caches splittés''' (''phased caches''), qui sont une variante des caches ''direct-mapped'', dans lequel le cache est accédé en deux étapes consécutives. Il ne s'agit pas des caches pipelinés, que nous verrons dans le chapitre sur les processeurs pipélinés, mais laissons cela à plus tard. Il est possible d'appliquer la même méthode sur un cache associatif par voie, mais il y a des méthodes plus simples, qui permettent là aussi d’accéder au cache en plusieurs étapes consécutives. L'idée est de scinder le cache en deux : une mémoire pour les tags, une autre pour les données de la ligne de cache. Les bits de contrôle peuvent être mis dans l'une ou l'autre SRAM, mais ils sont souvent mis dans la RAM pour les tags. En faisant cela, quelques optimisations deviennent possibles, afin de réduire la consommation énergétique en contrepartie d'une perte de performance. La technique s'implémente différemment pour les caches totalement associatifs et partiellement associatifs. Les caches totalement associatifs splittés sont ceux formés en combinant un cache associatif avec une CAM et une RAM combinée. On envoie l'adresse à lire/écrire à la mémoire associative, elle répond en envoyant une adresse à la mémoire RAM. L'accès se fait donc en deux temps, avec l'adresse dans la RAM comme intermédiaire. Il est possible de séparer physiquement les deux étapes en insérant un registre entre la CAM et la RAM, ce qui permet aussi de pipeliner l'accès. Mais c'est rarement fait en pratique, car le cout en circuit d'une mémoire CAM est trop important. L'équivalent pour un cache totalement associatif optimisé, sans CAM et RAM séparée, est trop gourmande en interconnexions pour être implémentée. Les caches totalement associatifs splittés sont donc très rares, l'auteur ne connait aucun exemple de processeur avec un tel cache. Il existe une technique équivalente pour les caches ''direct-mapped'', mais elle demande une certaine modification du cache. Dans les caches ''direct-mapped'' non-splittés, on trouve une mémoire SRAM dont chaque mot mémoire contient une ligne de cache entière, tag inclus. Dans leurs versions splittés, la SRAM est séparée en deux : une pour les tags, une autre pour les données. Précisons qu'il s'agit bien de deux mémoires SRAM adressables. L'adresse à laquelle accéder est envoyée à la SRAM des tags, puis ensuite à la SRAM des données si besoin. L'idée est d’accéder aux tags pour déterminer s'il y a un succès de cache ou un défaut, et ensuite d'accéder aux données. On n’accède pas aux données en parallèle des tags. Faire cela est évidemment plus lent. En cas de défaut de cache, le temps d'accès est similaire : le tag ne correspond pas, on n'accède pas à la SRAM pour les données. Par contre, vu qu'on n'a pas activé la SRAM pour les données, on économise un peu d'énergie, ce qui réduit la consommation d'énergie. En cas de succès de cache, on accède à la SRAM pour les tags, puis à celle pour les données. Pas d'économie d'énergie à l'horizon, sans compter que le temps d'accès augmente : on accède au cache en deux étapes au lieu de faire les deux accès en parallèle. [[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]] Précisons cependant que ce design peut avoir deux avantages en termes de performance. Premièrement, le temps d'accès au cache est légèrement amélioré en cas de défaut de cache. En effet, la SRAM des tags est assez petite, idem pour celle des données. Leur temps d'accès est donc plus faible que pour une grosse SRAM contenant données et tags. Le gain en temps d'accès est donc un avantage, qui ne se manifeste surtout en cas de défaut de cache. Un autre avantage est que l'accès au cache se pipeline plus facilement, ce qui fait qu'on peut effectuer plusieurs accès simultanés au cache. Mais nous verrons cela dans quelques chapitres. ===Le contrôleur de cache 82385 pour les CPU Intel 386=== Il est important de noter que la séparation entre tags et RAM peut être telle que les deux ne sont pas sur la même puce de silicium ! Voire que les deux sont séparés du processeur ! C'était le cas quand les mémoires caches ont été introduites sur les processeurs grand public, notamment sur les premiers processeurs Intel. La miniaturisation n'avait pas avancé au point où placer un cache dans le processeur était possible. Sur le processeur 386 d'Intel, le cache était un cache splitté, séparé du processeur. Concrètement, le processeur i386 était couplé à un contrôleur de cache Intel 82385 et une mémoire SRAM. Le 82385 contenait les ''tags'' et les bits de contrôle, la SRAM contenait les données, les lignes de cache. Un point important est que les lignes de cache faisaient seulement 32 bits/4 octets, pas plus ! On était loin des lignes de cache actuelles, faisant 64 octets/512 bits. Mais c'était beaucoup plus pratique, vu que le bus système faisait 32 bits de large, idem pour l'interface avec le processeur. Pour intégrer un cache facultatif (certains ordinateurs s'en passaient). Le schéma ci-dessous montre comment le cache s'intégrait avec le bus système. Pour le bus de commande, le cache servait d'intermédiaire : il recevait les commandes et les filtrait suivant les succès/défauts de cache. En cas de succès de cache, les commandes de lecture n'étaient pas envoyées à la mémoire RAM. Les adresses étaient transmises à la fois au cache et au bus système (avec un registre entre le bus système et le processeur). Le bus de donnée était lui connecté à la mémoire SRAM et au processeur, avec des MUX/DEMUX pour faire le choix de la source des lectures. [[File:Controleur de cache 82385 pour l'Intel 386.png|centre|vignette|upright=2.5|Contrôleur de cache 82385 pour l'Intel 386]] Le 82385 surveillait ce qui se passait sur le bus et répondait à la place de la RAM pour certaines lectures. C'était un intermédiaire assez passif, qui se contenait de répondre aux succès et défauts en lecture. Le cache était un cache ''write through'' un peu particulier. En cas de succès de cache pour une écriture, le cache met à jour sa ligne de cache et propage l'écriture en mémoire RAM. Par contre, si une écriture fait un défaut de cache, la donnée n'est pas écrite dans le cache. Le seul moyen pour copier une donnée dans le cache était un défaut pour une lecture. Le 82385 pouvait commander soit un cache ''direct mapped'', soit associatif à deux voies. Le choix entre les deux était le fait d'une entrée : la mettre à 0 indiquait un cache ''direct mapped'', la mettre à 1 forçait un cache à deux voies. La différence entre les deux est que le 82385 était relié à une mémoire SRAM avec un cache ''direct mapped'', deux SRAM pour deux voies. Pour avoir un cache associatif à deux voies, le 82385 devrait gérer deux signaux ''chip select'' pour activer chaque SRAM/voie suivant les besoins. Il avait précisément quatre signaux CS : deux par SRAM, un pour les lectures, un pour les écritures. Notons que les lignes de cache faisaient 32 bits, ce qui pouvait d'obtenir soit avec une SRAM 32 bits, soit avec deux SRAM 16 bits, soit avec 4 SRAM 8 bits. Le 82385 rajoutait 4 sorties, pour masquer chaque octet dans ces 32 bits, qui sont techniquement des signaux ''Output Enable'' pour 4 SRAM 8 bits. [[File:Interface entre le 82385 et la SRAM du cache.png|centre|vignette|upright=2|Interface entre le 82385 et la SRAM du cache. Beaucoup d'entrées et de sorties liées au bus d'adresse ne sont pas représentées.]] Il gérait aussi les accès mémoire non-cacheable, à savoir des accès mémoire qui ne doivent pas être pris en compte par le cache. Il considérait certains accès mémoire comme "à ne pas cacher". Notamment, les accès mémoire à une entrée-sortie ne sont pas cachés. Pour rappel, le processeur utilisait un espace d'adressage séparé pour les entrées-sorties, et utilisait donc un bit IO, qui était utilisé par le 82385 pour savoir si l'accès mémoire doit être caché ou non. Il en est de même pour les accès ayant lieu lors d'une interruption, qui ne passent pas par le cache. Mais au-delà de cette inhibition automatique du cache, le 82385 avait une entrée NCA (''Non Cacheable Access'') : le cache était "désactivé" quand cette entrée était à 1. C'est un peu une sorte de ''chip select'' pour le 82385, limitée aux accès mémoire. Cette entrée permettait de programmer des intervalles d'adresse auxquels ne pas répondre, en utilisant des circuits de décodage d'adresse adaptés. Il avait aussi une entrée X16, qui permettait d'identifier les accès soit à un composant 16 bits. De tels accès ne doivent pas être mis en cache, sans doute parce que cela ne collait pas avec la taille des lignes de cache (32 bits). Et cette entrée permettait d'inhiber ces accès 16 bits d'agir sur le cache, en utilisant le bit du bus de commande adéquat. Le 82385 pouvait être intégré dans un système à deux processeurs, voire plus. Pour cela, chaque processeur avait son propre 82385 et sa SRAM rien qu'à lui. Il n'y avait pas de cache partagé entre les deux processeurs. Par contre, les deux caches étaient reliés au même bus système. Pour qu'ils ne se marchent pas sur les pieds, il y avait des circuits d'arbitrage pour gérer l'accès au bus. Un des deux 82385 était mis en mode maitre, l'autre était en mode esclave. Le 82385 maitre pouvait prendre le contrôle du bus, le 82385 esclave devait demander l'autorisation au premier pour accéder au bus système. Le 82385 gérait une forme limitée de cohérence des caches par invalidation. Dès que le 82385 détectait une prise de contrôle du bus par autre chose que le processeur, il surveillait les adresses transmises sur le bus. En cas de succès de cache, la ligne de cache associée était invalidée. Au-delà de ça, le 82385 avait une entrée FLUSH, qui ordonnait une invalidation totale du cache. Si cette entrée est mise à 1, toutes les lignes de cache sont invalidées. Les ''tags'' sont marqués comme invalides, mais les lignes de cache elles-mêmes ne sont pas touchées. ===L'exemple des processeurs Intel de microarchitecture ''Broadwell''=== Un autre exemple est celui du cache L4 des processeurs Broadwell et de quelques processeurs séparés. Ces processeurs ont une organisation en ''chiplet'' où le processeur incorpore plusieurs puces séparées : une puce pour le processeur proprement dit, une puce nommée ''Crystal Well'' pour le cache L4, et une puce IO pour la communication avec la RAM et la carte mère. Le processeur incorporait un cache L4 de 128 mébioctets, composé de mémoire eDRAM, qui était dispersé entre ''Crystal Well'' et les autres puces. Les données du cache L4 étaient dans ''Crystal Well'', alors que les Tags étaient soit dans le processeur lui-même, soit dans la puce IO ! La puce ''Crystal Well'' était une mémoire DRAM adressable tout ce qu'il y a de plus basique, avec cependant quelques optimisations notables. Par exemple, elle avait deux bus séparés pour l'écriture et la lecture. De plus, elle avait une organisation interne avec 128 banques, contre moins d'une dizaine pour la DDR de l'époque et environ 32 banques pour la DDR5 moderne. Elle contenait aussi quelques circuits pour gérer son rôle de mémoire cache, mais rien en ce qui concerne la gestion des tags eux-mêmes. Sur les processeurs de microarchitecture ''Broadwell'', les tags étaient placés dans le CPU et précisément dans le cache L3. À chaque accès mémoire au cache L3, les tags du cache L4 étaient consultés en parallèle. De fait, l'accès au cache L4 était assez rapide, malgré le fait que les données étaient dans une puce à part. Ajoutons à cela que le processeur et ''Crystal Well'' n'avaient pas la même finesse de gravure ni la même technologie de fabrication. Les tags étaient implémentés avec de la SRAM contre la DRAM pour les données, ce qui fait que la consultation des tags était plus rapide que l'accès aux données. Par la suite, dans certains CPU de microarchitecture ''skylake'', les tags ont été déplacés en-dehors du processeur pour finir dans le contrôleur mémoire. En faisant cela, le cache L4 pouvait être utilisé par autre chose que le processeur, et notamment par la carte graphique intégrée au CPU. Avec ''broadwell'', le fait que les tags étaient consultés en cas d'accès au L3 empêchait au GPU intégré de consulter le cache L4. Mais en déplaçant les tags dans le contrôleur mémoire, ce n'est plus le cas vu que la carte graphique a aussi accès au bus mémoire. Par contre, le temps d'accès augmente comparé à la solution précédente. On n'accède pas aux tags du L4 en parallèle du L3 : à la place, il faut consulter les tags du L3, détecter un défaut de cache L3, et ensuite accèder aux tags. ===Les caches RAM-configurables=== Un autre avantage des caches splittés est qu'on peut les modifier pour servir à la fois de mémoire cache, mais aussi de ''local store'', de mémoire RAM de petite taille. Le fonctionnement est assez simple à comprendre. Lors d'un accès au cache, on accède aux tags, puis à la RAM interne au cache. Lors d'un accès au ''local store'', on contourne l'accès au tags et on accède à la RAM interne au cache directement. Il s'agit de la technique du '''cache RAM-configurable''. L'usage de cache RAM-configurable est fréquent sur les cartes graphiques récentes, qui incorporent un ou plusieurs processeurs multicoeurs, dont le cache L1 de données est un cache RAM-configurable. [[File:Hydride cache - local store.png|centre|vignette|upright=2.0|Hydride cache - local store]] ===La compression de cache=== Une autre optimisation permise par les ''phased caches'' est l'implémentation de techniques de '''compression de cache''', qui visent à compresser des lignes de cache. L'intérêt est qu'on peut stocker plus de données dans le cache, à capacité égale. L'inconvénient est qu'on doit compresser/décompresser les lignes de cache, ce qui demande un circuit en plus et allonge les temps d'accès. En effet, le temps mis pour compresser/décompresser une ligne de cache s'ajoute au temps d'accès. Aussi, la compression de cache sert surtout pour les caches de bas niveau dans la hiérarchie mémoire, les gros caches aux temps d'accès assez longs. Une première technique, assez simple à implémenter et peu couteuse en circuit, est celle de la '''compression des lignes de cache nulles'''. Elle compresse uniquement les lignes de cache qui ne contiennent que des zéros. L'idée est qu'on ajoute, dans la mémoire des tags, un bit de contrôle pour chaque ligne de cache appelé le bit ''null''. Il indique si la ligne de cache ne contient que des zéros. Quand on lit une ligne de cache, la mémoire des tags est accédée et on vérifie le bit ''null'' : s'il vaut 1, on n'accède pas à la mémoire cache de données et un multiplexeur envoie un zéro sur le port de lecture. Le bit ''null'' est fixé lors de l'écriture d'une ligne de cache : elle passe dans un comparateur avec zéro relié à la mémoire des tags. La comparaison avec zéro peut se faire en parallèle de l'écriture ou avant (dans ce cas, on n'écrit pas la ligne de cache dans le cache). Les autres techniques de compression de cache permettent de compresser autre chose que des lignes de cache nulles. L'idée est qu'une ligne de cache physique peut par moment mémoriser plusieurs lignes de caches compressées. Par exemple, prenons un cache dont les lignes de cache font 64 octets. Il est possible de compresser deux lignes de cache pour qu'elles fassent chacune 32 octets, et les stocker dans une seule ligne de cache. Les deux lignes de cache auront des tags différents, mais pointeront sur la même ligne de cache physique. Et cela demande d'utiliser un ''phased cache'' dont la mémoire pour les tags est plus grande que la mémoire pour les données. Il n'y a donc plus une bijection entre tags et ligne de cache, mais une relation surjective. Chose qui n'est possible qu'avec un ''phased cache''. De plus, des bits de contrôles associés à chaque ''tag'' indiquent où se trouvent les lignes de cache compressées dans la ligne de cache : est-ce que c'est les 32 octets de poids fort ou de poids faible ? [[File:Compression de cache.png|centre|vignette|upright=2|Compression de cache]] Il ne semble pas que les techniques de compression de cache soient implémentées sur les processeurs modernes. Aucun n'utilise de compression de cache, à ma connaissance. Il faut dire que les techniques connues sont de mauvais compromis : le temps d'accès du cache augmente beaucoup, le cout en circuit pourrait être utilisé pour un cache non-compressé mais plus grand. Et notons que la compression de cache ne marche que si les données peuvent se compresser. Si ce n'est pas le cas, une partie de la mémoire des tags est inutilisée. Une revue de la littérature académique sur la compression de cache est disponible via ce lien, pour les curieux : * [https://inria.hal.science/hal-03285041 Understanding Cache Compression, par Carvalho et Seznec]. ==Les caches adressés par somme et hashés== Les caches adressés par somme sont optimisés pour incorporer certains calculs d'adresse directement dans le cache lui-même. Pour rappel, certains modes d'adressage impliquent un calcul d'adresse, qui ajoute une constante à une adresse de base. Généralement, l'adresse de base est l'adresse d'un tableau ou d'une structure, et la constante ajoutée indique la position de la donnée dans le tableau/la structure. Les caches hashés et les caches adressés par somme permettent de faire l'addition directement dans la mémoire cache. Voyons d'abord les caches hashés, avant de passer aux caches adressés par somme. Sur les '''caches hashés''', l'addition est remplacée par une autre opération, par exemple des opérations bit à bit du style XOR, AND ou OR, etc. Seulement, utiliser des opérations bit à bit pose un problème : il arrive que deux couples Adresse/décalage donnent le même résultat. Par exemple, le couple Adresse/décalage 11101111/0001 donnera la même adresse que le couple 11110000/0000. Dit autrement, deux adresses censées être différentes (après application du décalage) sont en réalité attribuées à la même ligne de cache. Il est toutefois possible de gérer ces situations, mais cela demande des astuces de haute volée pour faire fonctionner la mémoire cache correctement. Sur les '''caches adressés par somme''', le décodeur est modifié pour se passer de l'addition. Pour comprendre comment, il faut rappeler qu'un décodeur normal est composé de comparateurs, qui vérifient si l'entrée est égale à une constante bien précise. Sur un cache ordinaire, l'addition est faite séparément du décodage des adresses par le cache, dans l'unité de calcul ou dans l'unité de génération d'adresse. [[File:Non sum adressed cache.png|centre|vignette|upright=2|Cache normal.]] Mais les caches adressés par somme modifient le décodeur, qui est alors composé de comparateurs qui testent si la somme adresse + décalage est égale à une constante. [[File:Cache adressé par somme.png|centre|vignette|upright=2|Cache adressé par somme.]] Chaque circuit du décodeur fait le test suivant, avec K une constante qui dépend du circuit : : <math>A + B = K</math> Ce qui est équivalent à faire le test suivant : : <math>A + B - K = 0</math> En complément à deux, on a <math>- K = \overline{K} + 1</math>. En injectant dans l'équation précédente, on a : : <math>A + B + \overline{K} + 1 = 0</math> En réorganisant les termes, on a : : <math>A + B + \overline{K} = - 1</math> Il suffit d'utiliser un additionneur ''carry-save'' pour faire l'addition des trois termes. Rappelons qu'un tel additionneur fournit deux résultats en sortie : une somme calculée sans propager les retenues et les retenues en question. Notons que les retenues sont à décaler d'un cran, vu qu'elles sont censées s'appliquer à la colonne suivante. En notant la somme S et les retenues R, on a: : <math>S + (R << 1) = - 1 </math>, le décalage d'un cran à gauche étant noté <math><< 1</math>. Ensuite, -1 est codé avec un nombre dont tous les bits sont à 1 en complément à un/deux. : <math>S + (R << 1) = 111 \cdots 111111</math> [[File:Sum + retenue add.png|centre|vignette|upright=2|Sum + retenue add]] Un simple raisonnement nous permet de savoir si le résultat est bien -1, sans faire l'addition <math>S + (R << 1)</math>. En effet, on ne peut obtenir -1 que si la somme est l'inverse des retenues : un 0 dans le premier nombre correspond à un 1 dans l'autre, et réciproquement. En clair, on doit avoir <math>\overline{S} = R << 1</math>. Pour vérifier cela, il suffit de faire un simple XOR entre la somme et les retenues décalées d'un cran. On a alors : : <math>S \oplus (R << 1) = 111 \cdots 111111</math> La comparaison avec -1 se fait avec une porte ET à plusieurs entrées. En effet, la porte donnera un 1 seulement si tous les bits d'entrée sont à 1, ce qui est ce qu'on veut tester. Au final, l'additionneur pour l'addition adresse + décalage est remplacé par un additionneur carry-save suivi d'une couche de portes XOR et d'un comparateur avec une constante, ce qui économise de circuits et améliore les performances. [[File:Final circuit of sum addressed cache.png|centre|vignette|upright=2|Cache adressé par somme.]] En prenant en compte que la constante K est justement une constante, certaines entrées de l'additionneur carry-save sont toujours à 0 ou à 1, ce qui permet quelques simplifications à grand coup d’algèbre de Boole. Chaque additionneur complet qui compose l’additionneur carry-save est remplacée par des demi-additionneurs (ou par un circuit similaire). Autant dire que l'on gagne tout de même un petit peu en rapidité, en supprimant une couche de portes logiques. Le circuit de décodage économise aussi des portes logiques, ce qui est appréciable. ==Les caches à accès uniforme et non-uniforme== Intuitivement, le temps d'accès au cache est le même pour toutes les lignes de cache. Il s'agit de cache appelés '''caches à accès uniforme''', sous-entendu à temps d'accès uniforme. Mais sur les caches de grande capacité, il arrive souvent que le temps de propagation des signaux varie fortement suivant la ligne de cache à lire. D'ordinaire, on se cale sur la ligne de cache la plus lente pour caler la fréquence d'horloge du cache, même si on pourrait faire mieux. Cependant, les '''caches à accès non uniforme''' ont une latence différente pour chaque ligne d'un même cache. Certaines lignes de cache sont plus rapides que d'autres. Niveau terminologie, nous allons parler de caches UCA et NUCA : ''Uniform Access Cache'' pour les caches à accès uniforme, ''Non-Uniform Access Cache'' pour les caches à accès non-uniforme. [[File:Caches UCA et NUCA.png|vignette|Caches UCA et NUCA.]] Les caches NUCA et UCA sont souvent composés de plusieurs banques séparées, typiquement une par voie. Sur les caches UCA, les banques sont interconnectées avec le processeur de manière à ce que toutes les interconnexions ont la même longueur pour toutes les banques. Typiquement, les banques sont organisées en carré, avec les interconnexions qui partent du centre, avec une disposition en H, illustrée ci-contre Mais avec les caches NUCA, ce n'est pas le cas. Les interconnexions sont simplifiées et ont des longueurs différentes. Les caches NUCA n'ont pas tous le même genre d'interconnexions, qui dépendent du cache NUCA. En général, les interconnexion forme un réseau avec des sortes de routeurs qui redirigent les données/commandes vers la bonne destination : cache ou processeur. Les banques plus proches du processeur sont accessibles plus rapidement que celles éloignées, même si la différence n'est pas énorme. Les caches NUCA sont généralement associatifs par voie. Les plus simples utilisent une banque par voie pour le cache, ce qui fait que certaines voies répondent plus vite que les autres. La détection des succès de cache est alors plus rapide si la donnée lue/écrite est dans une voie/banque rapide. En théorie, les défauts de cache demandent de vérifier toutes les banques, et se calent donc sur la pire latence. Mais divers caches se débrouillent pour que ce ne soit pas le cas, soit en vérifiant les banquyes unes par une, soit par un mécanisme de recherche plus complexe. Les caches NUCA sont surtout utilisés pour les caches L3 et L4, éventuellement les caches L2. Les caches L1 sont systématiquement des caches UCA, car la latence de l'accès au cache L1 est utilisée par le processeur pour décider quand lancer les instructions. Pour simplifier, le processeur peut démarrer en avance une instruction avant qu'une opérande soit lue dans le cache L1, de manière à ce que la donnée arrive en entrée de l'ALU pile en même temps que l'instruction. Une histoire d'exécution dans le désordre et d'émission anticipée des instructions qu'on détaillera dans une bonne dizaine de chapitres. Toujours est-il que tout est plus simple pour le processeur si le cache L1 a un temps d'accès fixe. Par contre, les caches L3 et L4 sont traités en attendant que les données arrivent, le processeur reprend l'exécution des instructions quand les caches L3 et L4 ont terminé de répondre, pas avant. Avec l'association une banque = une voie, la correspondance ligne de cache → bloc de mémoire qui est statique : on ne peut pas déplacer le contenu d'une ligne de cache dans une autre portion de mémoire plus rapide suivant les besoins. Mais la recherche académique a étudié le cas où la correspondance entre une ligne de cache et une banque varie à l’exécution. Pour nommer cette distinction, on parle de caches S-NUCA (''Static NUCA'') et D-NUCA (''Dynamic NUCA''). Intuitivement, on s'attend à ce que les caches D-NUCA soient plus performants que les caches S-NUCA. Les lignes de cache les plus utilisées peuvent migrer dans une banque rapide, alors que les lignes de cache moins utilisées vont dans une banque éloignée. Les lignes de cache se répartissent dans le cache dynamiquement dans les banques où elles sont le plus adaptées. Mais paradoxalement, le gain des caches D-NUCA est presque nul, voire insignifiant. La raison est que les caches D-NUCA doivent incorporer un système pour déterminer dans quelle banque se situe la donnée pour détecter les succès/défauts de cache, ainsi qu'un système pour migrer les données entre banques. Et ce système augmente le temps d'accès au cache, réduisant à néant l'intérêt d'un cache D-NUCA. Si on économise quelques microsecondes de temps d'accès en passant d'un cache UCA à un cache S-NUCA, ce n'est pas pour les perdre en passant à un D-NUCA. La majorité des caches D-NUCA sont donc en cours de recherche, mais ne sont pas utilisés en pratique. ==La tolérance aux erreurs des caches== Une mémoire cache reste avant tout une mémoire RAM, bien que ce soit de la SRAM. Elle n'est pas parfaite et est donc sujette à des erreurs, qui peuvent inverser un bit ou l'effacer. De telles erreurs sont liées à des rayons cosmiques très énergétiques, à des particules alpha produites par le packaging ou le métal deu circuit intégré, peu importe : l'essentiel est qu'ils inversent parfois un bit. Les mémoires modernes savent se protéger contre de telles erreurs, en utilisant trois moyens. ===Les mémoires caches ECC et à bit de parité=== Le premier moyen est l'usage de codes correcteurs d'erreurs, qui ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Les bits ajoutés dépendent de la donnée mémorisée dans le byte, et servent à détecter une erreur, éventuellement à la corriger. Le cas le plus simple ajoute un simple bit de parité pour chaque byte et se contente de détecter les erreurs dans les corriger. Les autres codes ECC permettent eux de corriger des erreurs, mais ils demandent d'ajouter au moins deux bits par byte, ce qui a un cout en circuit plus élevé. Un simple bit de parité permet de détecter qu'un bit a été inversé, mais ne permet pas de corriger l'erreur. En soi, ce n'est pas un problème. Si une erreur est détectée, on considère que la ligne de cache est invalide. Le cache gère la situation comme un défaut de cache et va chercher la donnée valide en mémoire RAM. Le cout en circuits est donc faible, mais les défauts de cache sont plus nombreux. Les codes ECC sont eux capables de corriger les erreurs, si elles ne modifient pas trop de bits d'un coup. Par contre, ils utilisent deux à trois bits par octet, ce qui a un cout en circuits loin d'être négligeable. Il y a donc un compromis entre défauts de cache et cout en circuits. La gestion de l'ECC est différente suivant le niveau de cache. Généralement, le cache L1 n'utilise pas l'ECC mais se contente d'un simple bit de parité pour éviter la corruption de ses données. Le cache étant petit, les corruptions de données sont assez rares, et les défauts de cache induits faibles. Il est plus important d'utiliser un code de détection d'erreur simple, rapide, qui ne ralentit pas le cache et n'augmente pas sa latence. Si une ligne de cache est corrompue, il a juste à aller lire la ligne depuis le cache L2, ou un niveau de cache inférieur. Du moins, c'est possible sur le cache en question est un cache inclusif et/ou ''write-through''. Par contre, le niveau de cache L2 et ceux en-dessous utilisent presque systématiquement une mémoire SRAM ECC. La raison principale étant que ce sont des caches assez gros, pour lesquels la probabilité d'une erreur est assez élevée. Plus une mémoire a de bits et prend de la place, plus il y a une chance élevée qu'un bit s'inverse. Et vu que les caches L2/L3/L4 sont par nature plus lents et plus gros, ils peuvent se permettre le cout en performance lié à l'ECC, idem pour le cout en circuit. Sans compter qu'en cas d'erreur, ils doivent aller lire la ligne de cache originelle en mémoire RAM, ce qui est très lent ! Mieux vaut corriger l'erreur sur place en utilisant l'ECC. ===L'usage du ''memory scrubbing'' sur les caches=== La plupart des erreurs ne changent qu'un seul bit dans un byte, mais le problème est que ces erreurs s'accumulent. Entre deux accès à une ligne de cache, il se peut que plusieurs erreurs se soient accumulées, ce qui dépasse les capacités de correction de l'ECC. Dans ce cas, il existe une solution appelée le ''memory scrubbing'', qui permet de résoudre le problème au prix d'un certain cout en performance. Pour rappel, l'idée est de vérifier les lignes de caches régulièrement, pour éviter que les erreurs s'accumulent. Par exemple, on peut vérifier chaque ligne de cache toutes les N millisecondes, et corriger une éventuelle erreur lors de cette vérification. En faisant des vérifications régulières, on garantir que les erreurs n'ont pas le temps de s'accumuler, sauf en cas de malchance avec des erreurs très proches dans le temps. Il ne s'agit pas d'un rafraichissement mémoire, car les SRAM ne s'effacent pas), mais ça a un effet similaire. Et évidemment, le ''memory scrubbing'' a un cout en performance. On peut faire une comparaison avec le rafraichissement mémoire : les rafraichissement réguliers réduisent les performances, car cela fait des accès en plus. Des accès qui sont de plus timés à des instants bien précis qui ne sont pas forcément les plus adéquats. Il est possible qu'un rafraichissement ait lieu en même temps qu'un accès mémoire et le rafraichissement a la priorité, ce qui réduit les performances. La même chose arrive avec les vérifications du ''memory scrubbing''. Malgré tout, la technique a été utilisée sur les caches de certains processeurs commerciaux, dont des processeurs AMD Athlon et Athlon 64. Elle est surtout utilisable sur les caches L2/L3, pour lesquels le cout du pseudo-rafraichissement est acceptable. ==Un exemple de cache : le cache d'instruction== La grande majorité des processeurs utilise deux caches L1 séparés : un '''cache d'instructions''' dédié aux instructions, et un autre pour les données. Une telle organisation permet de charger une instruction tout en lisant une donnée en même temps. Notons que seul le cache L1 est ainsi séparé entre cache de données et d'instructions. Le cache d’instruction se situe en théorie entre l'unité de chargement et l'unité de décodage. En effet, ce cache prend en entrée une adresse et fournit une instruction. L'adresse est fournie par le ''program counter'', l'instruction est envoyée dans l'unité de décodage. Le cache se situe donc entre les deux. Le cache de données L1 est connecté au chemin de données, et notamment aux unités de communication avec la mémoire, pas au séquenceur. [[File:Caches L1 et positions dans le processeur.png|centre|vignette|upright=2.5|Caches L1 et positions dans le processeur]] Les deux caches sont reliés au processeur par des bus séparés, l'ensemble ressemble à une architecture Harvard, mais où les caches remplacent les mémoires RAM/ROM. Le cache d'instruction prend la place de la mémoire ROM et le cache de données prend la place de la mémoire RAM. Évidemment, il y a des niveaux de caches en dessous des caches de données/instruction, et ceux-ci contiennent à la fois données et instructions, les deux ne sont pas séparées dans des mémoires/caches séparés. Raison pour laquelle l'ensemble est appelé une '''architecture Harvard modifiée'''. Architecture Harvard, car l'accès aux données et instructions se font par des voies séparées pour le processeur, modifiée car la séparation n'est effective que pour le cache L1 et pas les autres niveaux de cache, et encore moins la RAM. Sur les processeurs modernes, il arrive très souvent que le processeur doive charger une instruction et lire/écrire une donnée en même temps. Et à vrai dire, c'est la règle plus que l'exception. L'usage d'une architecture Harvard modifiée permet cela très facilement : on peut accéder au cache d'instruction via un bus, et au cache de donnée avec l'autre ===Pourquoi scinder le cache L1 en cache d'instruction et de données=== L'usage d'un cache d’instruction séparé du cache de données est à contraster avec l'usage d'un cache L1 multiport unique, capable de mémoriser à la fois instructions et données. Les deux solutions sont possibles ont été utilisées. Les premiers processeurs avaient un cache L1 unique et multiport, mais ce n'est plus le cas sur les processeurs modernes, car les contraintes ne sont pas les mêmes. Le compromis à faire est celui entre deux petits caches rapides et un gros cache plus lent. Pour rappel, plus un cache est petit, plus il est rapide et chauffe moins. Donc au lieu d'utiliser, par exemple, un gros cache lent de 64 Kibioctets, on utilise deux caches de 32 kibioctets, plus rapides. La capacité totale est la même, mais le temps d'accès plus faible. En termes de temps d'accès, la meilleure solution est celle des deux caches simple port. Mais pour ce qui est de l'économie de circuits, c'est moins évident. Entre deux mémoires simple port et une mémoire multiport, la différence en termes de transistors est ambigüe et dépend de la capacité des caches. La différence est surtout notable pour les gros caches, moins pour les petits caches. Il faut aussi tenir compte de la capacité effective. Avec deux caches séparés, la répartition de la capacité du cache L1 est fixée une bonne fois pour toutes. Par exemple, avec un cache d'instruction de 32 KB et un cache de données de 32 KB, impossible d'allouer 40 KB aux données et 20 aux instructions. Alors qu'avec un cache L1 unique de 64 KB, on pourrait le faire sans soucis. La répartition se fait naturellement, en fonction de la politique de remplacement du cache et est proche de l'optimal. C'est là un désavantage des caches d'instructions/données séparés : une capacité effective moindre. Tout cela explique pourquoi le cache L1 est le seul à être ainsi scindé en deux, avec une séparation entre instructions et données : les contraintes au niveau du cache L1 et L2 ne sont pas les mêmes. Pour les caches L1, le temps d'accès est plus important que la capacité, ce qui favorise les caches séparés. Par contre, pour les caches L2/L3/L4, le temps d'accès n'est pas déterminant, alors que la capacité effective et l'économie en circuits sont significatives. ===La connexion des caches L1 avec le cache L2=== Pour les connexions avec le cache L2, tout dépend du processeur. Certains utilisent un cache L2 multiport, qui permet aux deux caches L1 de lire ou écrire dans le cache L2 simultanément. [[File:Cache d'instructions.png|centre|vignette|upright=1.5|Cache d'instructions.]] Si le cache L2 ne gère pas les accès simultanés, il n'y a qu'un seul bus relié aux caches L1 et au cache L2. On doit effectuer un arbitrage pour décider quel cache a la priorité, chose qui est réalisé par un circuit d'arbitrage spécialisé. [[File:Circuit d'arbitrage du cache.png|centre|vignette|upright=1.5|Circuit d'arbitrage du cache.]] Généralement, les caches d'instructions peuvent se permettre d'être plus petits que les caches de données, car les programmes sont souvent plus petits que les données manipulées. Songez que des programmes de quelques mébioctets peuvent parfois remplir la RAM avec plusieurs gibioctets de données. Lancez votre navigateur internet et ouvrez une page web un peu chargée, pour vous en convaincre ! ===Les spécificités du cache d'instruction : lecture seule, bloquant, etc=== Les instructions sont rarement modifiées ou accédées en écritures, contrairement aux données. Et cela permet d'utiliser un cache simplifié pour les instructions. Autant un cache généraliste doit permettre les lectures et écritures depuis le processeur (avec les échanges avec la RAM), autant un cache d'instruction peut se contenter des lectures provenant du CPU et des échanges avec la RAM. Le cache d'instructions est donc très souvent en « lecture seule » : le processeur ne peut pas écrire dedans, mais juste le lire ou charger des instructions dedans. Un cache d'instruction est donc plus simple qu'un cache pour les données : on peut retirer les circuits en charge de l'écriture (mais on doit laisser un port d'écriture pour charger les instructions dedans). Le gain en circuits permet d'utiliser un cache d'instruction plus gros ou au contraire de laisser de la place pour le cache de données. Le gain en termes de capacité compense alors un peu les inconvénients des caches séparés. Par contre, cela complique la gestion du code automodifiant, c'est-à-dire des programmes dont certaines instructions vont aller en modifier d'autres, ce qui sert pour faire de l'optimisation ou est utilisé pour compresser ou cacher un programme (les virus informatiques utilisent beaucoup de genre de procédés). Quand le processeur exécute ce genre de code, il ne peut pas écrire dans ce cache L1 d'instructions, mais doit écrire dans le cache L2 ou en RAM, avant de recharger les instructions modifiées dans le cache L1. Cela qui prend du temps et peut parfois donner lieu à des erreurs si le cache L1 n'est pas mis à jour. Les algorithmes de remplacement des lignes de cache optimaux pour les données ne le sont pas pour les instructions, de même que la taille optimale du cache, la taille des lignes de cache optimale, ou même les algorithmes de préchargement. Par exemple, pour le remplacement des lignes de cache, un simple algorithme LRU est presque optimal pour les instructions, autant il peut donner de mauvaises performances quand on manipule beaucoup de tableaux. Cela justifie d'utiliser des caches spécialisés pour chacune. On peut adapter le cache d'instruction à son contenu, ce qui le rend plus rapide ou plus petit à performance égale. Les caches d'instructions sont généralement des caches bloquants. Il ne servirait à rien de rendre un cache d'instruction non-bloquant, le cout en circuits ne se traduirait pas par une augmentation significative des performances. À l'opposé, les caches de données sont non-bloquants sur les architectures modernes, pour des raisons de performance. Ce qui rend la séparation assez intéressante, les deux caches ayant des besoins différents et des implémentations différentes, cela permet d'optimiser le cout en transistors des caches. ===L'impact du cache d'instruction sur les performances=== Sur les architectures conventionnelles, le cache d'instruction a plus d'impact sur les performances que le cache de données. La raison principale est que les instructions ont une meilleure localité spatiale et temporelle que pour les données. Pour la localité spatiale, les instructions consécutives se suivent en mémoire, alors que rien ne garantit que des données utilisées ensemble soient regroupées en mémoire. Pour localité temporelle, elle est très variable pour les données, mais très courante pour les instructions du fait de l'usage fréquent des boucles et des fonctions. : La présence de branchements atténue la localité temporelle des instruction, sauf que la majorité des branchements sautent à un endroit très proche, seuls les appels de fonction brisent la localité spatiale. La conséquence est qu'il arrive que certains CPU aient un cache L1 d'instruction plus gros que celui pour les données. On parle alors de '''cache L1 asymétriques'''. Un exemple est celui des processeurs AMD de microarchitecture Zen, dont le cache d'instruction était deux fois plus gros que le cache de données. Leur cache d'instruction faisait 64 kibioctets, contre seulement 32 pour le cache de données. D'ailleurs, il existe des processeurs assez extrêmes qui se contentent d'un cache d'instruction unique, sans cache de données. C'est le cas sur les processeurs vectoriels ou les GPU que nous verrons dans les chapitres de fin de ce wikilivres. De tels processeurs sont spécialisés dans la manipulation de tableaux de données, traitement qui a une faible localité temporelle. En conséquence, utiliser un cache de données n'est pas vraiment utile, voire peu être contreproductif, alors qu'un cache d’instruction fonctionne parfaitement. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Compléments sur les mémoires de masse | prevText=Compléments sur les mémoires de masse | next=Le préchargement | nextText=Le préchargement }} </noinclude> gc6o0jdl7905ttwy5b4aiiox8ap6ok8 765170 765169 2026-04-26T20:44:41Z Mewtow 31375 /* Les caches splittés (phased caches) */ 765170 wikitext text/x-wiki Le cache est une mémoire intercalée entre la mémoire et un processeur, plus rarement à l'intérieur d'un périphérique. Il est souvent fabriquée avec de la mémoire SRAM, parfois avec de l'eDRAM. Sans lui, on se croirait à l'âge de pierre tellement nos PC seraient lents ! En effet, la mémoire est très lente comparée au processeur. Le temps mis pour accéder à la mémoire est du temps durant lequel le processeur n'exécute pas d'instruction (sauf cas particuliers impliquant un pipeline). Pour diminuer ce temps d'attente, il a été décidé d'intercaler une mémoire petite mais rapide, entre le processeur et la mémoire. Ainsi, le processeur accède à un cache très rapide plutôt qu'à une RAM beaucoup plus lente. ==L'accès au cache== Le cache contient une copie de certaines données présentes en RAM. La copie présente dans le cache est accessible bien plus rapidement que celle en RAM, vu que le cache est plus rapide. Mais seule une petite partie de ces données sont copiées dans le cache, les autres données devant être lues ou écrites dans la RAM. Toujours est-il que le cache contient une copie des dernières données accédées par le processeur. Une donnée est copiée dans la mémoire cache quand elle est lue ou écrite par le processeur. Le processeur conserve une copie de la donnée dans le cache après son premier accès. Les lectures/écritures suivantes se feront alors directement dans le cache. Évidemment, au fur et à mesure des accès, certaines données anciennes sont éliminées du cache pour faire de la place aux nouveaux entrants, comme nous le verrons plus tard. [[File:Principe d'une mémoire cache.gif|centre|vignette|upright=2|Principe d'une mémoire cache.]] La mémoire cache est invisible pour le programmeur, qui ne peut pas déceler celles-ci dans l'assembleur. Les accès mémoire se font de la même manière avec ou sans le cache. La raison à cela est que le cache intercepte les accès mémoire et y répond s'il en a la capacité. Par exemple, si le cache intercepte une lecture à une adresse et que le contenu de cette adresse est dans le cache, le cache va outrepasser la mémoire RAM et la donnée sera envoyée par le cache au lieu d'être lue en RAM. par contre, si un accès se fait à une adresse pour laquelle le cache n'a pas la donnée, alors l'accès mémoire sera effectué par la RAM de la même manière que si le cache n'était pas là. [[File:Accès au cache.png|centre|vignette|upright=2|Accès au cache]] ===Les succès et défauts de caches=== Tout accès mémoire est intercepté par le cache, qui vérifie si la donnée demandée est présente ou non dans le cache. Si la donnée voulue est présente dans le cache, on a un '''succès de cache''' (''cache hit'') et on accède à la donnée depuis le cache. Sinon, c'est un '''défaut de cache''' (''cache miss'') et on est obligé d’accéder à la RAM. Les défauts de cache peuvent avoir plusieurs origines. Tout ce qu'il faut savoir est que lorsque le processeur accède à une donnée ou une instruction pour la première fois, il la place dans la mémoire cache car elle a de bonnes chances d'être réutilisée prochainement. La raison à cela est qu'un programme a tendance à réutiliser les instructions et données qui ont été accédées dans le passé : c'est le ''principe de localité temporelle''. Bien évidement, cela dépend du programme, de la façon dont celui-ci est programmé et accède à ses données et du traitement qu'il fait, mais c'est souvent vrai en général. La première cause des défauts de cache est liée à la taille du cache. À force de charger des données/instructions dans le cache, le cache fini par être trop petit pour conserver les anciennes données. Le cache doit bien finir par faire de la place en supprimant les anciennes données, qui ont peu de chances d'être réutilisées. Ces anciennes données éliminées du cache peuvent cependant être accédées plus tard. Tout prochain accès à cette donnée mènera à un cache miss. C'est ce qu'on appelle un ''Capacity Cache Miss'', ou encore '''défaut de capacité'''. Les seules solutions pour éviter cela consistent à augmenter la taille du cache ou à optimiser le programme exécuté (voir plus bas). Une autre raison pour un défaut est donc la suivante. Lorsqu'on exécute à une instruction ou qu'on accède à donnée pour la première fois, celle-ci n'a pas encore été chargée dans le cache. Le défaut de cache est inévitable : ce genre de cache miss s'appelle un ''Cold Miss'', ou encore un '''défaut à froid'''. De tels défauts sont presque impossibles à éliminer, sauf à utiliser des techniques de préchargement qui chargent à l'avance des données potentiellement utiles. Ces méthodes de préchargement se basent sur le principe de localité spatiale, à savoir le fait que les programmes ont tendance à accéder à des données proches en mémoire. Pour donner un exemple, les instructions d'un programme sont placées en mémoire dans l’ordre dans lequel on les exécute : la prochaine instruction à exécuter est souvent placée juste après l'instruction en cours (sauf avec les branchements). Quand on accède à une donnée ou une instruction, le cache peut précharger les données adjacentes pour en profiter. Nous parlerons de ces techniques de préchargement dans un chapitre dédié, vers la fin du cours. ===Le fonctionnement du cache, vu du processeur=== Vu du processeur, le cache prend en entrée toutes les informations nécessaires pour effectuer un accès mémoire : des signaux de commande, une adresse et la donnée à écrire si besoin. Tout cela est passé en entrée du cache, celui-ci répondant aux accès mémoire via divers bits de contrôles, que le processeur peut lire à souhait. Le cache fournit aussi la donnée à lire, pour les lectures, sur une sortie, connectée directement au bus mémoire/processeur. Globalement, le cache a une capacité limitée, mais il prend en entrée des adresses complètes. Par exemple, sur un processeur 64 bits, le cache prend en entrée des adresses de 64 bits (sauf si optimisations), même si le cache en question ne fait que quelques mébioctets. Les caches sont souvent des mémoires multiports, surtout sur les processeurs récents. Les caches simple port sont rares, mêmes s'ils existent et ont existé par le passé. les caches double port sont eux plus fréquents, et ont généralement un port d'écriture séparé du port de lecture. Mais les caches récents ont plusieurs ports de lecture/écriture et sont capables de gérer plusieurs accès mémoire simultanés. Les données présentes dans le cache sont (pré)chargées depuis la mémoire, ce qui fait que toute donnée dans le cache est la copie d'une donnée en mémoire RAM. Le cache doit faire la correspondance entre une donnée du cache et l'adresse mémoire correspondante. Du point de vue du fonctionnement, on peut voir le cache comme une sorte de table de correspondance, qui mémorise des données, chacune étant associée à son adresse mémoire. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Cela vaut du point de vue du processeur, le fonctionnement interne du cache étant quelque peu différent selon le cache. Il existe des caches dont le fonctionnement interne est bien celui d'une table de correspondance matérielle, d'autres qui sont beaucoup plus optimisés. [[File:Fonctionnement d'une mémoire associative à correspondance.png|centre|vignette|upright=2|Fonctionnement simplifié d'une mémoire cache : les adresses sont dans la colonne de gauche, les données sont dans la colonne de droite. On voit qu'on envoie l'adresse au cache, que celui-ci répond en renvoyant la donnée associée.]] ==La performance des mémoires caches== L'analyse de la performance des mémoires caches est plus riche pour celle des autres mémoires. Sa performance dépend de beaucoup de paramètres, mais on peut cependant citer les principaux. Les deux premiers sont tout bonnement sa latence et son débit, comme pour n'importe quelle autre mémoire. La latence est plus importante que son débit, car le processeur est généralement plus rapide que le cache et qu'il n'aime pas attendre. Mais le critère le plus important pour un cache est sa capacité à empêcher des accès mémoire, son efficacité. Plus les accès mémoire sont servis par le cache au lieu de la RAM, meilleures seront les performances. Pour résumer, la performance d'un cache est surtout caractérisée par deux métriques : le taux de défaut, qui correspond à l’efficacité du cache, et la latence du cache. ===Le taux de succès/défaut=== Le '''taux de succès''' (hit ratio) est un premier indicateur des performances du cache, mais un indicateur assez imparfait. C'est le pourcentage d'accès mémoire qui ne déclenchent pas de défaut de cache. Plus il est élevé, plus le processeur accède au cache à la place de la RAM et plus le cache est efficace. Certains chercheurs préfèrent utiliser le '''taux de défauts''', à savoir le pourcentage d'accès mémoire qui entraînent un défaut de cache. Plus il est bas, meilleures sont les performances. Le taux de défaut est relié au taux de succès par l'équation <math>T_\text{succes} = 1 - T_\text{defaut}</math>. Par définition, il est égal à : : <math>\text{Taux de défauts de cache} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d’accès mémoires}}</math> Plutôt que de comparer le nombre de défauts/succès de cache au nombre d'accès mémoire, il est aussi possible de diviser le nombre de défauts par le nombre total d'instructions. On obtient alors le '''taux de défauts/succès par instruction''', une autre métrique utile. Par définition, elle est égale à : : <math>\text{Taux de défauts par instruction} = \frac{\text{Nombre de défauts de cache}}{\text{Nombre d'instructions}} = \text{Taux de défauts de cache} \times \frac{\text{Nombre d’accès mémoires}}{\text{Nombre d'instructions}}</math> Si certains défauts de cache sont inévitables quel que soit le cache, comme les défauts à froids, mentionnés plus haut, d'autres défauts peuvent être évités en augmentant la capacité du cache. C'est le cas des défauts de capacité qui sont causés par un accès à une donnée qui a été éliminée du cache faute de place. Plus le cache est gros, moins il a de chances d'être rempli, moins il doit rapatrier de données, plus son taux de succès augmente. Mais nous reviendrons sur le lien entre taille du cache et taux de défaut plus bas. Le taux de succès ne dépend pas que du cache, mais aussi de la conception des programmes exécutés. Une bonne utilisation du cache (ainsi que de la mémoire virtuelle) repose sur le programmeur qui doit prendre en compte les principes de localités dès la conception de ses programmes. Par exemple, un programmeur peut parfaitement tenir compte du cache au niveau de son algorithme : on peut citer l'existence des algorithmes ''cache oblivious'', qui sont conçus pour être optimaux quelle que soit la taille du cache. Le programmeur peut aussi choisir ses structures de données de manière à améliorer la localité. Par exemple, un tableau est une structure de donnée respectant le principe de localité spatiale, tandis qu'une liste chaînée ou un arbre n'en sont pas (bien qu'on puisse les implémenter de façon à limiter la casse). D'autres optimisations sont parfois possibles : par exemple, le sens de parcours d'un tableau multidimensionnel peut faire une grosse différence. Cela permet des gains très intéressants pouvant se mesurer avec des nombres à deux ou trois chiffres. Je vous recommande, si vous êtes programmeur, de vous renseigner le plus possible sur les optimisations de code ou algorithmiques qui concernent le cache : il vous suffira de chercher sur Google. Il y a une citation qui résume bien cela, prononcée par un certain Terje Mathisen. Si vous ne le connaissez pas, cet homme est un vieux programmeur (du temps durant lequel on codait encore en assembleur), grand gourou de l’optimisation, qui a notamment travaillé sur le moteur de Quake 3 Arena. {{BlocCitation|Almost all programming can be viewed as an exercise in caching.|auteur=Terje Mathisen}} ===La latence moyenne d'un cache=== Le temps mis pour lire ou écrire une donnée varie en présence d'un cache. Certaines lectures/écritures vont atterrir directement dans le cache (succès) tandis que d'autres devront aller chercher leur contenu en mémoire RAM (défaut de cache). Dans tous les cas, qu'il y ait défaut ou non, le cache sera consulté et mettra un certain temps à répondre, égal au temps de latence du cache. Tous les accès mémoires auront donc une durée au moins égale au temps de latence du cache, qui sera notée <math>T_c</math>. En cas de succès, le cache aura effectué la lecture ou l'écriture, et aucune action supplémentaire n'est requise. Ce qui n'est pas le cas en cas de défaut : le processeur devra aller lire/écrire la donnée en RAM, ce qui prend un temps supplémentaire égal au temps de latence de la mémoire RAM. Un défaut ajoute donc un temps, une pénalité, à l'accès mémoire. Dans ce qui suivra, le temps d'accès à la RAM sera noté <math>T_m</math>. Fort de ces informations, nous pouvons calculer le temps de latence moyen d'un accès mémoire, qui est la somme du temps d'accès au cache (pour tous les accès mémoire), multiplié par le temps lié aux défauts. On a alors : : <math>T = T_c + \text{Taux de défaut} \times T_m</math> On voit que plus le taux de succès est élevé, plus le temps de latence moyen sera bas, et inversement. Ce qui explique l'influence du taux de succès sur les performances du cache, influence assez importante sur les processeurs actuels. De nos jours, le temps que passe le processeur dans les défauts de cache devient de plus en plus un problème au fil du temps, et gérer correctement le cache est une nécessité, particulièrement sur les processeurs multi-cœurs. Il faut dire que la différence de vitesse entre processeur et mémoire est tellement importante que les défauts de cache sont très lents : alors qu'un succès de cache va prendre entre 1 et 5 cycles d'horloge, un cache miss fera plus dans les 400-1000 cycles d'horloge. Tout ce temps sera du temps de perdu que le processeur aura du mal à mitiger. Autant dire que réduire les défauts de cache est beaucoup plus efficace que d'optimiser les calculs effectués par le processeur (erreur courante chez de nombreux programmeurs, notamment débutants). ===L'impact de la taille du cache sur le taux de défaut et la latence=== Il y a un lien entre taille du cache, taux de défaut, débit binaire et latence moyenne. Globalement, plus un cache est gros, plus il est lent. Simple application de la notion de hiérarchie mémoire vue il y a quelques chapitres. Les raisons à cela sont nombreuses, mais nous ne pouvons pas les aborder ici, car il faudrait que nous sachions comment fonctionne un cache et ce qu'il y a à l'intérieur, ce qui sera vu dans la suite du chapitre. Toujours est-il que la latence moyenne d'un cache assez gros est assez importante. De même, le débit binaire d'un cache diminue avec sa taille, mais dans une moindre mesure. Les petits caches ont donc un gros débit binaire et une faible latence, alors que c'est l'inverse pour les gros caches. Une grande capacité de cache améliore le taux de succès, mais cela se fait au détriment de son temps de latence et de son débit, ce qui fait qu'il y a un compromis assez difficile à trouver entre taille du cache, latence et débit. Il peut arriver qu'augmenter la taille du cache augmente son temps d'accès au point d’entraîner une baisse de performance. Par exemple, les processeurs Nehalem d'Intel ont vus leurs performances dans certains jeux vidéos baisser de 2 à 3 %, malgré de nombreuses améliorations architecturales, parce que la latence du cache L1 avait augmentée de 2 cycles d'horloge. Pour avoir une petite idée du compromis à faire, regardons la relation entre taille du cache et taux de défaut. Il existe une relation approximative entre ces deux variables, appelée la '''loi de puissance des défauts de cache'''. Elle donne le nombre total de défaut de cache en fonction de la taille du cache et de deux autres paramètres. Voici cette loi : : <math>\text{Taux de défauts de cache} \approx K \times \text{Taille du cache}^{- \alpha }</math>, avec <math>K</math> et <math>\alpha</math> deux coefficients qui dépendent du programme exécuté. Le coefficient <math>\alpha</math> est généralement compris entre 0.3 et 0.7, guère plus, et varie suivant le programme exécuté. Précisons que cette loi ne marche que si le cache est assez petit par rapport aux données à utiliser. Pour un cache assez gros et des données très petites, la relation précédente est mise en défaut. Pour s'en rendre compte, il suffit d'étudier le cas extrême où toutes les données nécessaires tiennent dans le cache. Dans ce cas, il n'y a qu'un nombre fixe de défauts de cache : autant qu'il faut charger de données dans le cache. Le nombre de défauts de cache observé dans cette situation n'est autre que le coefficient <math>K</math> de la situation précédente, mais il n'y a aucune dépendance entre taux de défaut et taille du cache. L'origine de cette relation s'explique quand on regarde combien de fois chaque donnée est réutilisée lors de l’exécution d'un programme. La plupart des données finissent par être ré-accédées à un moment ou un autre et il se passe un certain temps entre deux accès à une même donnée. Sur la plupart des programmes, les observations montrent que beaucoup de réutilisations de données se font après un temps très court et qu'inversement, peu de ré-accès se font après un temps inter-accès long. Si on compte le nombre de réutilisation qui ont un temps inter-accès bien précis, on retrouve une loi de puissance identique à celle vue précédemment : : <math>\text{Nombre de réaccès avec un temps inter-accès égal à t} \approx K \times t^{- \beta}</math>, avec t le temps moyen entre deux réutilisations. Le coefficient <math>\beta</math> est ici compris entre 1.7 et 1.3. De manière générale, les coefficients <math>\alpha</math> et <math>\beta</math> sont reliés par la relation <math>\alpha = 1 - \beta</math>, ce qui montre qu'il y a un lien entre les deux relations. Précisons cependant que la loi de puissance précédente ne vaut pas pour tous les programmes informatiques, mais seulement pour la plupart d’entre eux. Il n'est pas rare de trouver quelques programmes pour lesquels les accès aux données sont relativement prédictibles et où une bonne optimisation du code fait que la loi de puissance précédente n'est pas valide. La loi de puissance des défauts de cache peut se démontrer à partir de la relation précédente, sous certaines hypothèses. Si un suppose que le cache est assez petit par rapport aux données, alors les deux relations sont équivalentes. L'idée qui se cache derrière la démonstration est que si le temps entre deux accès à une donnée est trop long, alors la donnée accédée aura plus de chance d'être rapatriée en RAM, ce qui cause un défaut de cache. La chance de rapatriement dépend de la taille du cache, un cache plus gros peut conserver plus de données et a donc un temps avant rapatriement plus long. ==Les lignes de cache et leurs tags== Du point de vue du processeur, les lectures et écritures se font mot mémoire par mot mémoire. Un processeur avec des entiers de 64 bits recoit des données de 64 bits de la part du cache, et y écrit des mots de 64 bits. Mais quand on regarde comment sont stockées les données à l'intérieur du cache, les choses sont différentes. ===Les lignes de cache=== Les données sont mémorisées dans le cache par blocs de plusieurs bytes, d'environ 64 à 256 octets chacun, qui portent le nom de '''lignes de cache'''. Les lignes de cache sont l'unité de stockage que l'on trouve à l'intérieur du cache, mais elles servent aussi d'unité de transaction avec la mémoire RAM. Sur les caches actuels, on transfère les données entre le cache et la RAM ligne de cache par ligne de cache, dans la limite de la taille du bus mémoire. Mais d'autres caches plus anciens permettaient de faire des transferts plus fins. C’est-à-dire qu'on pouvait mettre à jour quelques octets dans une ligne de cache sans avoir à la recopier intégralement depuis ou dans la mémoire RAM. En théorie, on pourrait imaginer des caches où les données sont stockées différemment, où l'unité serait le mot mémoire, par exemple. Par exemple, sur un processeur 64 bits, on aurait une ligne de cache de 64 bits. Cela aurait l'avantage de la simplicité : les transferts entre le processeur et la mémoire serait de même taille, l'intérieur du cache ressemblerait à son interface montrée au processeur. Mais cela aurait quelques défauts qui sont compensés par l'organisation en lignes de cache de grande taille. Le premier avantage des lignes de cache est lié à la localité spatiale, la tendance qu'on les programmes à accéder à des données proches les unes des autres. Des accès mémoires consécutifs ont tendance à se faire à des adresses proches, qui ont de bonnes chances d'être dans la même ligne de cache. Et des accès consécutifs à une même ligne de cache sont plus rapides que des accès à deux lignes distinctes. Une autre raison est tout simplement que cela simplifie considérablement la circuiterie du cache. Pour une capacité identique, il vaut mieux avoir peu de lignes de cache assez grosses, que beaucoup de petites lignes de cache. La raison est que les circuits du cache, comme le décodeur, l'encodeur et autres, ont moins de sorties et sont donc plus simples. ===L'alignement des lignes de cache=== Les lignes de cache sont des blocs de plusieurs dizaines à centaines de bytes, dont la taille est presque toujours une puissance de deux. De plus, les lignes de cache sont alignées en mémoire. Nous avions déjà abordé la notion d'alignement mémoire dans un chapitre précédent, mais le concept d'alignement des lignes de cache est quelque peu différent. Quand nous avions parlé d'alignement auparavant, il s'agissait de l'alignement des données manipulées par le processeur, qui faisait partie du jeu d'instruction du processeur. Ici, nous parlons d'un alignement totalement différent, invisible pour le programmeur, sans lien avec le jeu d’instruction. Voyons de quoi il retourne. Concrètement, cela veut dire que du point de vue du cache, la RAM est découpée en blocs qui font la même taille qu'une ligne de cache, aux positions prédéterminées, sans recouvrement entre les blocs. Par exemple, pour un cache dont les lignes de cache font 256 octets, le premier bloc est à l'adresse 0, le second est 256 octets plus loin, c'est à dire à l'adresse 256, le troisième à l'adresse 512, la quatrième à l'adresse 768, etc. Une ligne de cache de 256 octets contiendra une donnée provenant d'un bloc de RAM de 256 octets, dont l'adresse est systématiquement un multiple de 256. Il n'est pas possible qu'une ligne de cache contienne un bloc de 256 octets dont l'adresse du premier octet serait l'adresse 64, ou l'adresse 32, par exemple. En clair, les adresses de ces blocs sont des multiples de la taille de la ligne de cache, de la taille des blocs. Cela rappelle les contraintes d'alignement vues dans le chapitre "Le modèle mémoire : alignement et boutisme", mais appliquées aux lignes de cache. L'alignement des lignes de cache a des conséquences pratiques pour la conception des caches. Notons qu'il est en théorie possible d'avoir des caches dont les lignes de cache ne sont pas alignées, mais cela poserait des problèmes majeurs. Il serait en effet possible qu'une donnée soit présente dans deux lignes de cache à la fois. Par exemple, prenons le cas où une ligne de cache de 256 commence à l'adresse 64 et une autre ligne de cache commence à l'adresse 0. L'adresse 128 serait dans les deux lignes de cache ! Et cela poserait des problèmes lors des lectures, mais encore plus lors des écritures. C'est pour éviter ce genre de problèmes que les lignes de cache sont alignées avec la mémoire RAM dans tous les caches existants. L'alignement des lignes de cache est une chose que les programmeurs doivent parfois prendre en compte quand ils écrivent du code ultra-optimisé, destiné à des programmes demandant des performances extrêmes. Il arrive que les contraintes d'alignement posent des problèmes. Nous avions vu dans le chapitre sur le boutisme et l'alignement qu'il valait mieux gérer l'alignement des variables des structures de données, pour éviter les accès non-alignés avec le bus mémoire. La même chose est possible, mais pour l'alignement avec des lignes de cache. Typiquement, l'idéal est que, pour une structure de donnée, on puisse en mettre un nombre entier dans une ligne de cache. Ou alors, si la structure est vraiment grande, que celle-ci occupe un nombre entier de lignes de cache. Si ce n'est pas le cas, il y a un risque d'accès non-alignés, c'est à dire qu'une structure se retrouve à cheval sur deux lignes de cache, avec les défauts que cela implique. ===Le tag d'une ligne de cache=== Plus haut, nous avions dit que le cache mémorise, pour chaque ligne de cache, l'adresse RAM associée. Le cache contient donc des paires adresse-ligne de cache qui lui permettent de faire le lien entre ligne de cache et adresse. Mais du fait de l'organisation du cache en lignes de cache de grande taille, qui sont de plus alignées en mémoire, il faut nuancer cette affirmation. Le cache ne mémorise pas la totalité de l'adresse, ce qui serait inutile. L'alignement des lignes de cache en RAM fait que les bits de poids faible de l'adresse ne sont pas à prendre en compte pour l'association adresse-ligne de cache. Dans ces conditions, on mémorise seulement la partie utile de l'adresse mémoire correspondante, qui forme ce qu'on appelle le '''tag'''. Le reste de l'adresse indique quelle est la position de la donnée dans la ligne de cache. Par exemple, prenons le cas où le processeur gère des nombres entiers de 64 bits (8 octets) et des lignes de cache de 128 octets : chaque ligne de cache contient donc 16 entiers. Si le processeur veut lire ou écrire un entier bien précis, il doit préciser sa place dans la ligne de cache. Et ce sont les bits de l'adresse mémoire non-inclus dans le cache qui permettent de faire ça. En clair, une adresse mémoire à lire/écrire est interprété par le cache comme la concaténation d'un tag et de la position de la donnée dans la ligne de cache correspondante. [[File:Adressage d'un cache totalement associatif.png|centre|vignette|upright=2|Adressage d'un cache totalement associatif]] Le cache est donc une grande table de correspondance entre tags et lignes de cache. Lors d'un accès mémoire, le cache extrait le tag de l'adresse à lire ou écrire, et le compare avec les tags de chaque ligne de cache. Si une ligne contient ce tag, alors c'est que cette ligne correspond à l'adresse, et c'est un défaut de cache sinon. Lors d'un succès de cache, la ligne de cache est lue depuis le cache et envoyée à un multiplexeur qui sélectionne la donnée à lire dans la ligne de cache. Le fonctionnement est similaire pour une écriture : la donnée à écrire passe dans un démultiplexeur, qui envoie la donnée au bon endroit dans la ligne de cache sélectionnée. [[File:Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.png|centre|vignette|upright=2|Lecture d'une donnée dans un cache CPU, organisé en lignes de cache.]] ===Le contenu d'une ligne de cache=== Dans ce qui va suivre, nous allons considérer que chaque ligne de cache mémorise son tag, les données de la ligne de cache proprement dit, et quelques bits de contrôle annexes qui varient suivant le cache considéré. [[File:Tag d'une ligne de cache.png|centre|vignette|upright=2|Tag d'une ligne de cache.]] Les caches modernes incluent de nombreux bits de contrôle, mais deux d'entre eux sont communs à presque tous les caches modernes : le bit ''Dirty'' et le bit ''Valid''. Le '''bit ''Valid''''' indique si la ligne de cache contient des données valides ou non. Si le bit ''Valid'' est à 0, la ligne de cache est en état valide, à savoir qu'elle contient des données et n'est pas vide. Par contre, si ce bit est à 1, la ligne de cache est invalide et son contenu ne peut pas être lu ou écrit. L'utilité de ce bit est qu'il permet d'effacer une ligne de cache très rapidement : il suffit de mettre ce bit à 0. Il existe des situations où le cache doit être effacé, on dit alors qu'il est invalidé. Une section de ce chapitre sera dédié à l'invalidation du cache. Le '''bit ''Dirty''''' indique qu'une ligne de cache a été modifiée. Par modifiée, on veut dire que le processeur a écrit dedans, qu'il a modifié la ligne de cache. Mais attention : si la donnée a été modifiée dans le cache, la modification n'est pas forcément propagée en mémoire RAM. Le bit ''dirty'' indique si c'est le cas, si l'écriture a été propagée en mémoire RAM. Il précise que la ligne de cache contient des données modifiées, alors que la RAM a des données initiales non-modifiées. Une ligne de cache avec un bit ''dirty'' à 1 est dite ''dirty'', par métonymie. Nous verrons cela en détail dans la section sur les caches ''write-back'' et ''write-through''. Les caches modernes ajoutent des '''bits de détection/correction d'erreur''' dans les bits de contrôle. Pour rappel, les codes de détection/correction d'erreur permettent de se prémunir contre des erreurs matérielles, qui corrompent les données stockées dans une mémoire, ici une mémoire cache. Ils ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Nous reviendrons dessus dans une section ultérieur de ce chapitre. Sur certains caches assez anciens, on pouvait transférer les lignes de caches morceaux par morceaux. Ces caches avaient des lignes de cache divisées en sous-secteurs, ces sous-secteurs étant des morceaux de ligne de cache qu'on pouvait charger indépendamment les uns des autres (mais qui sont consécutifs en RAM). Chaque secteur avait ses propres bits de contrôle, mais le tag était commun à tous les secteurs. [[File:Cache à secteurs.png|centre|vignette|upright=2.5|Cache à secteurs.]] : Dans ce qui va suivre, le terme "ligne de cache" désignera soit un bloc de données copiées depuis la RAM d'une taille de 64/128/256/... octets, soit la concaténation de ces données avec le tag et des bits de contrôle. Les deux définitions ne sont pas équivalentes, mais l'usage a entériné cet abus de langage. Et il faut avouer que cela rend les explications du chapitre plus simples. ==Les instructions de contrôle du cache== Plus haut, nous avions dit que le cache est totalement transparent du point de vue du programmeur. Le cache contient des copies de données en RAM, le programmeur n'a rien à faire pour utiliser le cache correctement. Mais la réalité est que pour des raisons diverses, des processeurs incorporent des '''instructions de contrôle du cache'''. Il s'agit d’instructions qui agissent sur le contenu du cache. Elles existent pour des raisons diverses qu'on détaillera plus bas, mais il s'agit globalement d'une question de performances ou de nécessité pour le système d'exploitation. ===Les instructions de préchargement=== La première instruction de contrôle du cache est une '''instruction de préchargement''', qui demande à charger un bloc de données dans le cache. Elle prend en opérande une adresse mémoire, et le contenu de cette adresse est chargé dans une ligne de cache. Bien sûr, des contraintes d'alignement sont à prendre en compte : on charge un bloc de la même taille qu'une ligne de cache, aligné en mémoire sur la taille du bloc, qui contient l'adresse. L'instruction de préchargement n'est utile que si l'instruction est exécutée bien avant que la donnée ne soit utilisée/lue/écrite. Cela permet de charger une donnée dans le cache à l'avance, d'où le nom de préchargement donné à cette technique. Mais les processeurs modernes gérent des techniques de préchargement automatique, qui ne requièrent pas d'instructions de préchargement. Le préchargement automatique et les instructions de préchargement sont deux solutions complémentaires, mais qui peuvent se marcher sur les pieds. Nous en reparlerons dans le prochain chapitre, qui sera dédié au préchargement automatique. Il faut noter que les instructions de préchargement peuvent être ignorées par le processeur. Sous certaines conditions, le processeur peut décider que l'instruction de préchargement ne sera pas exécutée. Par exemple, il ne va pas précharger une donnée déjà présente dans le cache. Ou encore, si le bus mémoire est occupé, il ne va pas exécuter le préchargement, par manque de ressources matérielles. ===Les instructions d'invalidation et de ''flush''=== Les instructions ''flush'' regroupent deux types d'instructions qui sont souvent utilisées en même temps. Il s'agit des instructions d'invalidation et de nettoyage (''clean''). Les deux termes proviennent de la terminologie ARM, il n'y a pas de terminologie standardisé pour les noms de ces instructions. Dans les grandes lignes, elles permettent de vider le cache, à savoir de rapatrier son contenu en RAM et de réinitialiser le cache à zéro. Elles sont utilisées par le système d'exploitation lors des commutations de contexte, à savoir quand on passe d'un programme à un autre. Elles sont aussi utilisées lors des appels systèmes et routines d'interruption/exception. L'idée est de vider le cache avant d'exécuter un nouveau programme ou une nouvelle routine. Le nouveau programme aura accès à un cache tout propre, les données de l'ancien programme auront été retirée du cache. Les '''instructions ''clean''''' recopient le contenu de la ligne de cache en RAM. Elles forcent la recopie immédiatement de la ligne de cache en mémoire RAM. Pour faire leur travail, elle vérifient si la ligne de cache a été modifiée, avant de la recopier en RAM. Et pour cela, ils vérifient le bit de contrôle ''dirty'', qui est mis à 1 après une première écriture. Si ce bit est à 0, alors pas besoin de recopier la ligne de cache : elle n'a pas été modifiée, la RAM a déjà la bonne copie. Mais s'il est à 1, le cache et la RAM n'ont pas le même contenu, la recopie s'exécute. Les '''instructions d'invalidation''' permettent d'invalider une ligne de cache, à savoir d'effacer son contenu. Nous verrons à quoi servent ces instructions dans la section sur les changement de processus. Invalider une ligne de cache est une opération optimisée : le cache n'est en réalité pas réellement effacé. À la place, le bit ''Valid'' de chaque ligne de cache est juste mis à 0. Il faut noter que l'invalidation efface les lignes de cache sans se préoccuper de leur contenu. Elle se moque qu'une ligne de cache contienne une donnée modifiée, ''dirty'' ou quoique ce soit : la ligne de cache est effacée, point. Il est possible d'invalider une ligne de cache en fournissant une adresse mémoire, mais il est aussi possible d'invalider le cache tout entier. Le choix entre les deux dépend du mode d'adressage de l'instruction d'invalidation. Parfois, il existe une instruction séparée pour invalider tout le cache, et une autre pour invalider une ligne de cache bien précise. Des instructions séparées sont parfois disponibles pour invalider les caches de données et d'instructions, parfois aussi la TLB (un cache qu'on verra dans quelques chapitres). Il est possible de n'invalider que le cache L1, voire le cache L2. Il faut noter que l'invalidation efface tout le cache, mais ne se préoccupe pas de vérifier si les données ont été modifiées dans le cache. Pour certains caches, comme le cache d'instruction, ce n'est pas un problème, vu qu'il est en "lecture seule". Mais pour les caches de données, les données modifiées sont perdues en cas d'invalidation. Heureusement, il existe des instructions d'invalidation qui fusionnent une instruction ''clean'' et une instruction d'invalidation. Il s'agit d''''instructions d'invalidation spéciales'''. ===Les instructions d'optimisation : instructions non-temporelles et écritures optimisées=== Les '''instructions mémoire non-temporelles''' contournent complètement le cache. Par exemple, une lecture peut lire une donnée, mais celle-ci ne sera pas chargée dans le cache, elle passe directement de la RAM vers les registres. Une section entière de ce chapitre sera dédiée au contournement du cache, à savoir aux situations où les accès mémoire doivent passer directement du processeur à la RAM sans passer par le cache. D'autres instructions assez rares incorporent des indications pour le cache. Par exemple, l'instruction ''load last'' des processeurs POWER PC implique que la donnée ne sera utilisée qu'une seule fois. Elle est donc chargée dans le cache, mais la ligne de cache est configurée de manière à être remplacée très rapidement, typiquement avec une valeur de LRU/LFU adéquate. La donnée est bien chargée dans le cache, au cas où elle doive être relue suite à une mauvaise prédiction de branchement ou autre, chose qu'une lecture non-temporelle (qui contourne le cache) ne fait pas. Des indications de ce type sont appelées des '''''cache hint'''''. L''''instruction ''flush''''' permet de préciser qu'une ligne de cache contient une donnée inutile, qui ne sera pas réutilisée par le programme. Pas besoin de la conserver dans le cache, elle peut laisser sa place à des données plus utiles. Or, sans indication, les algorithmes de remplacement d'une ligne de cache risquent de conserver cette donnée trop longtemps, ce qui entraine une certaine pollution du cache par des données inutiles. Une autre instruction est elle beaucoup plus importante : celle de '''pré-allocation sur écriture'''. Elle sert dans le cas où une ligne de cache est complétement écrite. Par exemple, imaginons qu'on veuille écrire dans une portion de mémoire. Si celle-ci n'est pas dans le cache, le processeur va charger une ligne de cache complète depuis la RAM, écrire dans la ligne de cache, puis recopier la ligne de cache modifiée en mémoire RAM. Une écriture en RAM demande donc de faire une lecture et une écriture. Mais les instructions de pré-allocation sur écriture permettent de prévenir qu'une ligne de cache sera intégralement écrite, et qu'il n'y a donc pas besoin de lire celle-ci depuis la RAM. Notons que l'instruction d'écriture qui suit n'est pas une écriture non-temporelle, vu que les données sont écrites dans la ligne de cache, qui est ensuite envoyée en mémoire RAM dès que nécessaire. De plus, les données écrites peuvent ensuite être relue depuis le cache si nécessaire. Enfin, certains processeurs MIPS incorporent une instruction pour modifier le tag d'une ligne de cache. Elles servent à optimiser les copies mémoire, à savoir quand on copie un bloc de données d'un endroit à un autre. L'idée est de charger le bloc de données dans le cache avec une instruction LOAD/PREFETCH, de modifier le tag pour qu'il pointe vers l'adresse à écrire, et de laisser faire le cache pour que l'écriture se fasse en RAM. Mais les contraintes pour utiliser cette instruction sont assez drastiques : les données doivent être alignées sur la taille d'une ligne de cache, le bloc de départ et d'arrivée (l'original versus la copie) ne doivent pas se recouvrir, etc. ==L'associativité des caches et leur adressage implicite== Lorsqu'on souhaite accéder au cache, il faut trouver quelle est la ligne de cache dont le tag correspond à l'adresse demandée. On peut classifier les caches selon leur stratégie de recherche de la ligne correspondante en trois types de caches : totalement associatifs, directement adressés (''direct mapped'') et associatifs par voie. ===Les caches totalement associatifs=== Avec les caches totalement associatifs, toute donnée chargée depuis la mémoire peut être placée dans n'importe quelle ligne de cache, sans aucune restriction. Ces caches ont un taux de succès très élevé, quand on les compare aux autres caches. [[File:Cache totalement associatif.png|centre|vignette|upright=2|Cache totalement associatif.]] Concevoir un cache totalement associatif peut se faire de deux grandes manières différentes. La première consiste tout simplement à combiner une mémoire associative avec une mémoire RAM, en ajoutant éventuellement quelques circuits annexes. La mémoire associative mémorise les tags, alors que la mémoire RAM mémorise les données de la ligne de cache, éventuellement avec quelques bits de contrôle. La ligne de cache est stockée à une adresse A dans la mémoire RAM et son tag est stocké à la même adresse, mais dans la mémoire CAM. Ce faisant, quand on envoie le tag à la mémoire CAM, elle renvoie l'adresse de la ligne de cache dans la mémoire RAM. Cette adresse est alors envoyée directement sur le bus d'adresse de la RAM, et la lecture est effectuée automatiquement. Il faut ajouter quelques circuits annexes pour garantir que les écritures se passent correctement dans les deux mémoires, mais rien de bien terrible. [[File:Cache fabriqué avec une mémoire associative et une RAM.png|centre|vignette|upright=3|Cache fabriqué avec une mémoire associative et une RAM]] Il est cependant possible d'optimiser un tel cache, en fusionnant la mémoire CAM et la mémoire RAM, afin d'éliminer des circuits redondants. Pour comprendre pourquoi, rappelons que les mémoires CAM sont composées d'un plan mémoire, d'un paquet de comparateurs et d'un encodeur. Quant à la mémoire RAM, elle est composée d'un décodeur connecté au plan mémoire. En mettant une CAM suivie d'une RAM, on a un encodeur dont l'entrée est envoyée à un décodeur. [[File:Cache totalement associatif naif.png|centre|vignette|upright=3|Cache totalement associatif naif]] Or, le décodeur réalise l'opération inverse de l'encodeur, ce qui fait que mettre les deux composants à la suite ne sert à rien. On peut donc retirer l'encodeur et le décodeur, et envoyer directement les résultats des comparateurs sur les entrées de commande du plan mémoire de la RAM. [[File:Cache totalement associatif optimisé.png|centre|vignette|upright=2|Cache totalement associatif optimisé]] Avec cette méthode, les circuits du cache ressemblent à ce qui illustré ci-dessous. Le tag est envoyé à chaque ligne de cache. Le tag envoyé est alors comparé avec le Tag contenu dans chaque ligne de cache, comme c'est le cas sur les mémoires associatives. Si une ligne de cache matche avec le tag envoyé en entrée, la ligne pour laquelle il y a eu une égalité est alors connectée sur les lignes de bit (''bitlines''). Cela est réalisé par un circuit commandé par le comparateur de la ligne de cache. Il ne reste plus qu'à sélectionner la portion de la ligne de cache qui nous intéresse, grâce à un paquet de multiplexeurs. Cela permet d'effectuer une lecture ou écriture, mais il faut aussi préciser si il y a eu un défaut de cache ou un succès. Un succès de cache a lieu quand au moins des comparaisons est positive, alors que c'est un défaut de cache sinon. En clair, détecter un succès de cache demande juste de connecter une porte OU à plusieurs entrées à tous les comparateurs. [[File:Organisation générale d'un cache totalement associatif.png|centre|vignette|upright=2|Organisation générale d'un cache totalement associatif.]] ===Les caches directement adressés=== Les caches directement adressés peuvent être vus comme un cache totalement associatif auquel on aurait ajouté des restrictions assez drastiques. Plus haut, on a vu qu'un cache totalement adressé est équivalent à la combinaison d'une CAM avec une RAM. La mémoire CAM prend en entrée un Tag et traduit celui-ci en une adresse qui commande la mémoire RAM interne au cache. Dans ce qui suit, l'adresse interne au cache sera appelé l''''indice''' pour éviter toute confusion. [[File:Cache hash table - 2.png|centre|vignette|upright=2|Fonctionnement interne du cache, expliquée sous forme abstraite, en utilisant la notion d'indice interne au cache.]] Les caches directement adressés cherchent à remplacer la mémoire CAM par un circuit combinatoire. Ce circuit traduit le Tag en indice, mais est beaucoup plus simple qu'une mémoire CAM. Mais qui dit circuit plus simple dit circuit plus limité. Un circuit combinatoire n'est pas aussi versatile que ce qui est permis avec une mémoire CAM. En conséquence, une restriction majeure apparait : toute adresse mémoire est associée dans une ligne de cache prédéfinie, toujours la même. L'association entre ligne de cache et adresse mémoire est faite par le circuit combinatoire, et ne peut pas changer. Les concepteurs de caches s'arrangent pour que des adresses consécutives en mémoire RAM occupent des lignes de cache consécutives, par souci de simplicité. Tout se passe comme suit la mémoire RAM était découpés en blocs de la même taille que le cache. La première adresse du bloc est associée à la première ligne de cache (celle d'indice 0), la seconde adresse est associée à la seconde adresse du_ bloc, et ainsi de suite. Le tout est illustré ci-dessous. [[File:Cache adressé directement.png|centre|vignette|upright=2|Cache adressé directement.]] Avec cette contrainte, le circuit de traduction de l'adresse en adresse mémoire pour la RAM interne au cache est drastiquement simplifié, et disparait même. Une partie de l'adresse mémoire sert à indiquer la position de la donnée dans le cache, le reste de l'adresse sert encode le tag et la position de la donnée dans le ligne de cache. [[File:Cache line.png|centre|vignette|upright=2|Adresse d'une ligne de cache sur un cache adressé directement.]] Un cache directement adressé est conçu avec une RAM, un comparateur, et un paquet de multiplexeurs. En général, la mémoire RAM stocke les lignes de caches complète. Il arrive que l'on utilise deux mémoires RAM : une pour les tags et une pour les données, mais cette technique augmente le nombre de circuits et de portes logiques nécessaires, ce qui réduit la capacité du cache. L'index à lire/écrire est envoyé sur l'entrée d'adresse de la RAM, la RAM réagit en mettant la ligne de cache sur sa sortie de donnée. Sur cette sortie, un comparateur compare le tag de la ligne de cache lue avec le tag de l'adresse à lire ou écrire. On saura alors si on doit faire face à un défaut de cache. Ensuite, un multiplexeur récupère la donnée à lire/écrire. [[File:Direct mapped cache - french.png|centre|vignette|upright=2|Cache directement adressé.]] L'accès à un cache directement adressé a l'avantage d'être très rapide vu qu'il suffit de vérifier une seule ligne de cache : celle prédéfinie. Mais ces caches ne sont cependant pas sans défauts. Vu que le cache est plus petit que la mémoire, certaines adresses mémoires se partagent la même ligne de cache. Si le processeur a besoin d’accéder fréquemment à ces adresses, chaque accès à une adresse supprimera l'autre du cache : tout accès à l'ancienne adresse se soldera par un défaut de cache. Ce genre de défauts de cache causés par le fait que deux adresses mémoires ne peuvent utiliser la même ligne de cache s'appelle un '''défaut par conflit''' (''conflict miss''). Les défauts par conflit n'existent pas sur les caches totalement associatifs. En conséquence, le taux de succès des caches directement adressés est assez faible comparé aux autres caches. [[File:Cache Block Basic Conflict.svg|centre|vignette|upright=1.5|Exemple de ''Conflict Miss''.]] ===Les caches associatifs par voie=== Les caches associatifs par voie sont un compromis entre les caches directement adressés et les caches totalement associatifs. Pour simplifier, ces caches sont composés de plusieurs caches directement adressés accessibles en parallèle, chaque cache/RAM étant appelé une '''voie'''. Avec ces caches, toute adresse mémoire en RAM est associée à une ligne de cache dans chaque voie. [[File:Cache associatif par voie.png|centre|vignette|upright=2|Cache associatif par voie.]] Le schéma ci-dessous compare un cache directement adressé et un cache associatif à deux voies. On voit que chaque adresse est associée à une ligne de cache bien précise avec un cache directement dressé, et à deux lignes de cache avec un cache associatif à deux voies. L'adresse sera associée à 4 lignes de cache sur un cache associatif à 4 voies, à 8 lignes pour un cache à 8 voies, etc. L'ensemble des lignes de cache associées à une adresse est appelé un '''ensemble'''. [[File:Cache Fill.svg|centre|vignette|upright=2|Comparaison entre un cache directement adressé et un cache associatif à deux voies.]] Sur ces caches, toute adresse est découpée en trois parties : un tag, un index, et un décalage, comme sur les caches directement adressés. Comme vous pouvez le voir, l'organisation est identique à celle d'un cache totalement associatif, à part que chaque ensemble tag-ligne de cache est remplacé par une mémoire RAM qui en contient plusieurs. [[File:Implémentation d'un cache associatif par voie.png|centre|vignette|upright=2|Implémentation d'un cache associatif par voie.]] Le risque de conflits d'accès au cache est donc réduit sur un cache associatif à plusieurs voies, et il est d'autant plus réduit que le cache a de voies. Par contre, leur conception interne fait qu'ils ont un temps d'accès légèrement élevé que les caches directement adressés. Les caches associatifs par voie ont donc un taux de succès et un temps d'accès intermédiaire, situé entre les caches directement adressés et totalement associatifs. Ils sont une sorte de compromis entre réduction des défaut par conflits d'accès au cache et temps d'accès, et complexité des circuits. ==Les optimisations des caches associatifs par voie== Les caches partiellement associatifs regroupent les caches associatifs par voie et directement adressés, ainsi que leurs variantes. En clair : tous les caches qui ne sont pas totalement associatifs. Ils peuvent être optimisés de nombreuses manières, que ce soit pour gagner en performance ou pour économiser de l’énergie. Dans cette section, nous allons voir quelles sont ces optimisations. ===Les caches pseudo-associatifs=== Les caches adressés par voie contiennent une mémoire SRAM par voie. En théorie, les voies sont accédées en parallèles, en même temps, afin de voir si l'on a un succès de cache ou un défaut. Les '''caches pseudo-associatifs''' sont identiques aux caches associatifs par voie, si ce n'est qu'ils vérifient chaque voie une par une. Ils ont été utilisés sur des processeurs commerciaux, un exemple étant l'IBM 370. Là encore, on perd en performance pour gagner en consommation d'énergie. Le temps d'accès dans le meilleur des cas est plus faible pour les caches pseudo-associatifs, mais le pire des cas teste tous les caches avant de tomber sur le bon. Les performances sont donc réduites. Mais la consommation énergétique est meilleure, vu qu'on ne vérifie pas forcément toutes les voies en parallèle. On teste la première voie, éventuellement la seconde, peut-être la troisième, etc. Mais dans le cas général, on ne teste qu'une partie des voies, pas toutes, ce qui donne un gain en termes d'énergie. L'implémentation de caches de ce genre demande que l'on parcoure les voies une par une, en commençant de la première jusqu'à la dernière. Pour cela, un simple compteur suffit. Suivant la valeur du compteur, la voie associée est activée puis accédée. Toute la complexité revient à ajouter un circuit qui prend la valeur du compteur, et active la voie associée, lance un accès mémoire dessus. Vu que les voies sont chacune des caches ''direct mapped'', il suffit pour cela de geler les entrées d'adresse, soit en les déconnectant, soit en utilisant du ''clock gating'' ou de l'évaluation gardée. Les détails d'implémentation, non-cités ici, varient selon le cache. ===La prédiction de voie=== Pour réduire le temps d'accès des caches pseudo-associatifs, certains chercheurs ont inventé la '''prédiction de voie''', qui consiste à faire des paris sur la prochaine voie accédée. L'idée est d'accéder à la voie qui contient la donnée voulue du premier coup, en lisant celle-ci en priorité. Dans son implémentation la plus simple, le cache reste un cache pseudo-associatif. Lors d'un accès au cache, les voies sont toutes parcoures une par une. Par contre, les voies ne sont donc pas parcourues de la première vers la dernière, mais dans un ordre différent. Cette technique permet de mettre en veille les voies sur lesquels le processeur n'a pas parié, ce qui permet de diminuer la consommation énergétique du processeur. C'est plus efficace que d'aller lire plusieurs données dans des voies différentes et de n'en garder qu'une. L'implémentation est assez simple : il suffit d'ajouter un circuit de prédiction de voie,relié au compteur de voie. Une amélioration de la technique fait fonctionner le cache comme un intermédiaire entre cache pseudo-associatif et associatif par voies. L'idée est de chercher la voie prédite en premier, puis de chercher dans toutes les voies en parallèle en cas de défaut de cache. Au lieu d'attendre que les comparaisons de tags donnent leur résultat, le processeur sélectionne automatiquement une voie et configure les multiplexeurs à l'avance. Si le processeur ne se trompe pas, le processeur accède à la donnée plus tôt que prévu. S'il se trompe, le processeur annule la lecture effectuée en avance et recommence en faisant un accès en parallèle aux autres voies. Le compromis entre performance et consommation d'énergie est alors différent. On économise de l'énergie par rapport à un cache associatif par voie, au prix d'une petite perte de performance (doublement des temps d'accès). Mais par rapport à un cache pseudo-associatif, l'économie d'énergie est bien moindre, au prix d'un gain en performance assez manifeste. Prédire quelle voie sera la bonne est assez simple. En vertu du principe de localité, les accès futurs ont des chances de tomber dans les voies les plus fréquemment utilisées ou dans celle plus récemment utilisée. Il suffit de retenir la voie la plus récemment accédée dans un registre, qui sera utilisée comme prédiction. Pour vérifier que la prédiction est correcte, il suffit de comparer le registre et le résultat obtenu après vérification des tags. Cependant, on peut complexifier l'implémentation pour prendre en compte l'adresse à lire/écrire, l'instruction à l'origine de l'accès mémoire ou tout autre paramètre utile. Par exemple, des instructions différentes ont tendance à aller chercher leurs données dans des ensembles différents et la voie à choisir n'est pas la même. Pour cela, il suffit d'utiliser un cache pour stocker la correspondance instruction - voie. Pour plus de simplicité, la mémoire cache des prédictions est parfois remplacée par une RAM, qui est adressée : * soit par le program counter de l'instruction à l'origine de l'accès (en réalité, seulement quelques bits de poids faible de l'adresse) ; * soit par l'adresse à accéder (là encore, quelques bits de poids faible) ; * soit (pour les modes d'adressage qui utilisent un registre de base et un décalage) par un XOR entre les bits de poids faible de l'adresse de base et le décalage ; * soit par autre chose. ===La mise en veille sélective des voies=== Les caches associatifs ont tendance à utiliser beaucoup d'énergie, même quand on n'y accède pas. Aussi, certains processeurs détectent quand le cache est peu utilisé et en profitent pour mettre en veille les voies inutilisées. Vous vous demandez certainement ce qui se passe quand une donnée à lire/écrire est dans une voie désactivée. La réponse est que le cache détecte cette situation, car elle déclenche un succès de cache. Les ''tags'' ne sont en effet pas désactivés, seules les données sont mises en veille. L'implémentation est plus simple sur les caches qui séparent les tags et les données dans deux RAM différentes. Cette optimisation marche surtout sur les gros caches, qui ont des chances d'avoir une portion significative d’inutilisée (pas assez de données pour les remplir), donc généralement les caches L3/L4. Par exemple, les processeurs d'Intel de microarchitecture Ivy Bridge disposent d'un cache de 8 mébioctets à 16 voies, qu'ils peuvent faire passer à 512 kibioctets si le besoin s'en fait sentir. Quand ces processeurs détectent une faible activité, ils mettent en veille 14 voies et n'en gardent que 2 d'actives. Évidemment, les 14 voies sont vidées avant d'être mises en veille, afin qu'une aucune donnée ne soit perdue. ===Les caches ''skew-associative''=== Vous aurez remarqué que dans une voie, les lignes sont accédées en adressage direct : les défauts par conflit sont possibles sur un cache associatif par voie. Pour éviter cela, certains chercheurs ont créé des '''caches ''skew associative''''' (ou associatifs à biais). Pour faire simple, les index des lignes de cache subissent un petit traitement avant d'être utilisés. Le traitement en question est différent suivant la voie de destination, histoire que deux adresses mémoires avec des index identiques donnent des index différents après traitement. Le traitement en question est souvent une permutation des bits de l'index, qui est différente suivant la voie prise, ou un simple XOR avec un nombre qui dépend de la voie. [[File:Implémentation d'un cache skew associative.jpg|centre|vignette|upright=2|Implémentation d'un cache skew associative.]] ==L'adressage physique ou logique des caches== Le cache utilise les adresses à lire/écrire pour déterminer s'il a une copie de la donnée en son sein. Mais l’interaction entre caches et mémoire virtuelle donne lieu à un petit problème : l'adresse utilisée est-elle une adresse virtuelle/logique ou physique ? La réponse varie suivant le processeur : certains caches utilisent l'adresse virtuelle, tandis que d'autres prennent l'adresse physique. On parle de cache '''virtuellement tagué''' dans le premier cas et de cache '''physiquement tagué''' dans le second. {| |[[File:Cache tagué virtuellement.png|vignette|Cache tagué virtuellement.]] |[[File:Cache tagué physiquement.png|vignette|Cache tagué physiquement.]] |} ===L'accès à un cache physiquement/virtuellement tagué=== La manière d'accéder à un cache dépend de s'il est virtuellement ou physiquement tagué. Il faut utiliser l'adresse virtuelle pour les premiers, physique pour les seconds. Avec un cache virtuellement tagué, l'adresse logique peut être envoyée directement au cache. La MMU ne traduit les adresses que s'il faut accéder à la mémoire RAM. Ces caches sont donc plus rapides. Avec un cache physiquement tagué, le processeur doit traduire l'adresse logique en adresse physique dans la MMU, avant d'accéder au cache. La traduction d'adresse se fait soit en accédant à une table des pages en mémoire RAM, soit en accédant à un cache spécifiquement dédié à accélérer la traduction d'adresse, la TLB (''Translation Lookaside Buffer''). Dans la quasi-totalité des cas, la traduction d'adresse passe par la TLB, ce qui fait qu'elle est raisonnablement rapide. Toujours est-il que chaque accès au cache demande d'accéder à la TLB et de faire la traduction d'adresse avant d'accéder au cache. L'accès est donc plus lent que sur les caches virtuellement tagués, où les accès sont plus directs. [[File:Virtual and Physical addressing.svg|centre|vignette|upright=2|Cache tagué virtuellement versus physiquement tagué.]] ===Les défauts des caches virtuellement tagués=== Les caches physiquement tagués sont moins rapides que les caches virtuellement adressés. Pourtant, les caches virtuellement tagués sont peu fréquents sur les processeurs modernes. Et la raison est assez intéressante : c'est une question d'adresses homonymes et synonymes. ====Les droits d'accès doivent être vérifiés lors d'un accès au cache==== Un premier problème est que la protection mémoire est compliquée avec de tels caches. Rappelons que certaines portions de mémoire sont accessibles seulement en lecture, ou sont interdites en écriture, sont inexécutables, etc. Ces droits d'accès sont gérés par la MMU, qui vérifie pour chaque accès mémoire que l'accès est autorisé. En bypassant la MMU, l'accès au cache virtuellement tagué ne permet pas de faire ces vérifications. Il est possible de charger une donnée en lecture seule dans le cache, mais d'y faire des accès en écriture pour les accès ultérieurs. Les solutions à cela sont multiples. La première consiste à consulter la MMU en parallèle de l'accès au cache. L'accès au cache est alors réalisé de manière spéculative, et est ensuite confirmé/annulé une fois que la MMU a rendu son verdict. Les performances du cache restent alors les mêmes : l'accès à la MMU se fait en parallèle de l'accès au cache, pas avant. Une autre solution est d'ajouter les droits d'accès en question dans la ligne de cache, dans les bits de contrôle situés après le Tag. Chaque accès au cache récupère ces bits de contrôle et vérifie si l'accès est autorisé. L'inconvénient est que les lignes de cache deviennent plus longues, les droits d'accès sont dupliqués entre MMU et cache. Mais si le budget en transistor suit, ce n'est rien d'insurmontable. ====Les adresses homonymes perturbent la gestion du cache==== Pour rappel, une adresse logique homonyme correspond à plusieurs adresses physiques différentes. Elles surviennent quand chaque programme a son propre espace d'adressage. Dans ce cas, une adresse logique correspondra à une adresse physique différente par programme.Une autre manière de voir les choses est qu'il y a en réalité deux adresses homonymes, qui ont la même valeur, mais appartiennent à des espaces d'adressage différentes. Et c'est cette seconde interprétation que nous allons utiliser. Les caches doivent gérer ces adresses homonymes et faire en sorte que la lecture/écriture d'une adresse homonyme se fasse à la bonne adresse physique, dans la bonne ligne de cache. Et autant un cache physiquement tagué n'a aucun problème avec ça, vu qu'il ne gère que des adresses physiques, autant des problèmes surviennent avec les caches virtuellement tagués. Le problème est que les caches virtuellement tagués doivent faire la différence entre deux adresses homonymes de même valeur. Pour corriger ces problèmes, il existe deux grandes méthodes. La première méthode est simple : '''vider les caches''' en changeant de programme. Leur contenu est rapatrié en mémoire RAM, puis les caches sont remis à zéro. Le vidage du cache recopie les lignes de cache ''dirty'' (modifiées) en RAM, puis efface/invalide tout le cache. C'est à cela que servent les instructions ''clean'' et d'invalidation vues plus haut, elles ont été inventées pour cette situation précise. Lorsque le système d'exploitation déclenche une commutation de contexte, à savoir qu'il change le programme en cours d'exécution, le processeur vide tous les caches du processeur. Les interruptions font la même chose, elles vide tous les caches du processeur. Une seconde méthode numérote chaque programme en cours d'exécution, chaque processus. Le numéro attribué est spécifique à chaque processus, ce qui fait qu'il est appelé un '''identifiant de processus CPU'''. Le processeur mémorise l'identifiant du programme en cours d'exécution dans un registre dédié. L'identifiant de processus CPU est utilisé lors des accès mémoire. Chaque ligne de cache contient le numéro de l'espace d'adressage associé, dans son ''tag''. Lors de chaque accès mémoire, l'ID du registre est comparé à l'ID de la ligne de cache accédée, pour vérifier que l'accès mémoire accède à la bonne donnée. Cette méthode n'est pas très économe en termes de transistors. L'usage d'identifiant de processus CPU est clairement meilleure en termes de performance, les commutations de contexte sont plus rapides. Par contre, le budget en transistor est plus important. Un autre défaut de cette méthode est que l'identifiant de processus est généralement codé sur une dizaine de bits, alors que le système d'exploitation utilise des identifiants de processus beaucoup plus larges, de 32 à 64 bits sur les CPU 32/64 bits. L'OS doit gérer la correspondance entre identifiants de processus CPU et ceux de l'OS. Parfois, pour cette raison, les OS n'utilisent pas toujours ce système d'identifiant de processus CPU. ====Les adresses synonymes perturbent aussi la gestion du cache==== La gestion des adresses synonymes est aussi un gros problème sur les caches virtuellement tagués. Pour rappel, il s'agit du cas où des adresses logiques différentes pointent vers la même adresse physique. Typiquement, quand deux programmes se partagent un morceau de mémoire, ce morceau correspondra à des adresses synonymes dans les deux espaces d'adressage. Mais il arrive que l'on ait des adresses synonymes dans le même espace d'adressage, ce n'est pas si rare ! Autant les adresses synonymes ne posent aucun problème avec les caches physiquement tagués, ce n'est pas le cas avec les caches virtuellement adressés. Sur ces caches, deux adresses logiques synonymes vont tomber dans deux lignes de cache différentes. Corriger ce problème demande d'ajouter des circuits annexes pour détecter les adresses synonymes, qui sont vraiment complexes et ont un cout en termes de performance. Aussi, les caches virtuellement tagués sont très peu utilisés sur les processeurs modernes. ===Les caches virtuellement adressés, mais physiquement tagués=== Si les caches physiquement et virtuellement tagués ont des défauts, il existe un intermédiaire qui est un bon compromis entre ces deux extrêmes. Il s'agit des '''caches virtuellement adressés - physiquement tagués''', aussi appelés '''caches pseudo-virtuels'''. Pour comprendre comment ils fonctionnent, précisons que ces caches sont soit des caches ''direct-mapped'', soit des caches associatifs par voie (composés de plusieurs RAM ''direct-mapped'' accédées en parallèle, plusieurs voies). L'accès à ce genre de cache se fait en deux temps : on accède à un ou plusieurs RAM ''direct-mapped'' et on vérifie ensuite les ''Tags'' pour sélectionner la bonne voie. Sur les caches ''direct-mapped'', on n'a qu'une seule RAM ''direct-mapped''. Sur les caches associatifs, on a plusieurs RAM ''direct-mapped'', appelées des voies, qui sont accédées en parallèle. L'accès se fait donc en deux étapes : adresser les RAM ''direct-mapped'' avec un indice, vérifier les ''tags'' avec le reste de l'adresse. Une autre chose à rappeler est que l'adresse logique est composée de deux parties : un numéro de page logique qui indique dans quel page se situe l'adresse, un décalage/''offset'' qui indique la position de l'adresse dans la page. La traduction d'adresse transforme le numéro de page logique en numéro de page physique, mais laisse le décalage intouché. L'idée est d'utiliser le décalage pour adresser les RAM avec le décalage, tandis que le numéro de page sert de ''tag''. Le décalage est découpé en deux lors de l'accès au cache : les bits de poids fort forment l'indice (l'adresse envoyée à la voie), les bits de poids faible donnent la position de l'adresse dans la ligne de cache. L'idée est d'utiliser un numéro de page physique pour les ''tags'', mais d'adresser les voies avec le décalage logique. Les deux servent à des instants différents : vérification des ''tags'' pour l'adresse physique, accès aux voies pour l'adresse logique. Ainsi, le problème des adresses synonymes ou homonymes est résolu par l'utilisation de l'adresse physique pour les tags. Par contre, l'accès au cache est plus rapide, car on utilise l'adresse logique pour la première étape. Le processeur accède à la TLB et récupère l'adresse physique pendant que l'on adresse les voies, les deux sont faits en parallèle, ce qui fait que tout se passe comme si l'accès à la TLB était gratuit. La TLB étant assez rapide comparé au cache, l'adresse physique est disponible quand on doit faire la comparaison avec les ''tags''. [[File:Virtual - Physical - Pseudo Virtual addressing.svg|centre|vignette|upright=2|Adressage pseudo virtuel des caches.]] Il s'agit d'un excellent compromis entre performance et correction des problèmes des adresses synonymes/homonymes. Tous les caches des processeurs haute performance utilisent cette méthode, au moins pour leurs caches L1. Les caches L2 tendent à utiliser des caches physiquement adressés, pour lesquels la latence d'accès est suffisante pour qu'on accède à la TLB en amont. La raison est assez simple à expliquer, elle provient d'une contrainte assez précise sur le calcul de l'indice. La conséquence est qu'un cache ''direct-mapped'' ne peut pas dépasser la taille d'une page, soit 4 kibioctets sur les ordinateurs actuels. Sur les caches associatifs, on peut dépasser cette limite en augmentant le nombre de voies, mais la taille maximale d'une voie reste celle d'une page. Cette contrainte n'est pas trop grave sur les caches de petite taille, dont les caches L1. La plupart d'entre eux ont trouvé un compromis idéal avec moins d'une dizaine de voies par cache, chacun de 4 kibioctets, ce qui donne des caches allant de 16 à 64 kibioctets, soit entre 4 et 16 voies. Par contre, un cache de grande taille doit utiliser un grand nombre de voies, ce qui est peu pratique. Aussi, cette technique de caches pseudo-virtuels n'est pas toujours appliquée sur les caches L2, qui sont physiquement adressés. Il faut dire qu'on accède au cache L2 lors d'un défaut dans le cache L1, et l'adresse physique est disponible à ce moment-là, elle a déjà été récupérée lors de l'accès au cache L1. On peut donc l'utiliser pour adresser le cache L2 sans perte de performance. ==Le remplacement des lignes de cache== Lorsqu'un cache est rempli et qu'on charge une nouvelle donnée dedans, il faut faire de la place pour cette dernière. Dans le cas d'un cache directement adressé, il n'y a rien à faire vu que la ligne de cache à évincer est déterminée lors de la conception du cache. Mais pour les autres caches, la donnée peut aller dans n'importe quelle ligne ou voie. Or, le choix des données à rapatrier en RAM doit être le plus judicieux possible : on doit virer de préférence des données inutiles. Rapatrier une donnée qui sera surement utilisée sous peu est inutile, et il vaudrait mieux supprimer des données qui ne serviront plus ou alors dans longtemps. Il existe différents algorithmes spécialement dédiés à résoudre ce problème efficacement, directement câblés dans les unités de gestion du cache. Certains sont vraiment très complexes, aussi je vais vous présenter quelques algorithmes particulièrement simples. Mais avant de voir ces algorithmes, il faut absolument que je vous parle d'une chose très importante. Quel que soit l'algorithme en question, il choisit la ligne de cache à évincer et recopie son contenu dans la RAM. Ce qui demande d'identifier et de sélectionner une ligne de cache parmi toutes les autres. Pour cela, le circuit de remplacement attribue une adresse chaque ligne de cache ! Vous avez bien vu : chaque ligne de cache est numérotée par une adresse, interne au cache. ===Le remplacement aléatoire=== Premier algorithme : la donnée effacée du cache est choisie au hasard ! C'est contre-intuitif, mais cet algorithme donne des résultats assez honorables, en plus d'utiliser très peu de portes logiques (un générateur de nombres pseudo-aléatoire est un circuit assez simple). Généralement, les défauts de cache sont séparés par un nombre assez important et irrégulier de cycles d'horloge. Dans ces conditions, cette technique donne un bon résultat. ===FIFO : first in, first out=== Avec l'algorithme FIFO, la donnée effacée du cache est la plus ancienne, celle chargée dans le cache avant les autres. Cet algorithme est très simple à implémenter en circuit, concevoir une mémoire de type FIFO n'étant pas très compliqué, comme on l’a vu dans le chapitre dédié à ce type de mémoires. Et on peut dire que dans le cas d'un cache, l'implémentation est encore plus simple et se contente d'un seul registre/compteur. Typiquement, il suffit d'ajouter un registre qui mémorise où se situe la donnée la plus récente. Toute insertion d'une nouvelle donnée se fait à l'adresse suivante, ce qui demande juste d'incrémenter le registre avant d'utiliser son contenu pour l'accès mémoire. [[File:Algorithme FIFO de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme FIFO de remplacement des lignes de cache.]] Cet algorithme possède une petite particularité sur les caches associatifs par voie : en augmentant le nombre d'ensembles, les performances peuvent se dégrader : c'est ce qu'on appelle l''''anomalie de Bélády'''. ===MRU : most recently used=== Avec l'algorithme MRU, la donnée remplacée est celle qui a été utilisée le plus récemment. Cet algorithme s'implémente simplement avec un registre, dans lequel on place le numéro de la dernière ligne de cache utilisée. Cet algorithme de remplacement est très utile quand un programme traverse des tableaux du premier élément jusqu'au dernier : les données du tableau sont rarement réutilisées, rendant le cache inutile. Il est prouvé que dans ces conditions, l'algorithme MRU est optimal. Mais dans toutes les autres conditions, cet algorithme a des performances assez misérables. ===LFU : least frequently used=== Avec l'algorithme LFU, la donnée supprimée est celle qui est utilisée le moins fréquemment. Cet algorithme s'implémente en associant un compteur à chaque ligne de cache, qui est incrémenté à chaque accès mémoire. La ligne la moins récemment utilisée est celle dont le compteur associé a la plus petite valeur. Implémenter cet algorithme prend pas mal de transistors, car il faut rajouter autant de compteurs qu'il y a de lignes de cache, en plus d'un circuit pour comparer les compteurs et d'un encodeur. [[File:Algorithme LFU de remplacement des lignes de cache.png|centre|vignette|upright=2|Algorithme LFU de remplacement des lignes de cache]] ===LRU : least recently used=== Avec l'algorithme LRU, la donnée remplacée est celle qui a été utilisée le moins récemment. Cet algorithme se base sur le principe de localité temporelle, qui stipule qu'une donnée accédée récemment a de fortes chances d'être réutilisée dans un futur proche. Et inversement, la donnée la moins récemment utilisée du cache est celle qui a le plus de chance de ne servir à rien dans le futur. Autant la supprimer en priorité pour faire de la place à des données potentiellement utiles. Implémenter l'algorithme LRU peut se faire de différentes manières, qui ont pour point commun d'enregistrer les accès au cache pour en déduire la ligne la moins récemment accédée. La manière la plus simple demande d'utiliser un compteur pour chaque ligne de mémoire cache, un peu comme le LFU. La différence avec le LFU est que le compteur n'est pas incrémenté lors d'un accès mémoire. À la place, ce compteur est incrémenté régulièrement, chaque incrémentation ayant lieu en même temps pour tous les compteurs. Quand un bloc est chargé dans le cache, ce compteur est mis à zéro. Quand une ligne de cache doit être remplacée, un circuit va vérifier la valeur de tous les compteurs : la ligne LRU (la moins récemment utilisée), est celle dont le compteur a la valeur la plus haute. Le circuit est composé d'un paquet de comparateurs, et d'un encodeur, comme pour l'agorithme LFU. ===Les approximations du LRU=== Implémenter le LRU demande un nombre de transistors proportionnel au carré du nombre de lignes de cache. Autant dire que le LRU devient impraticable sur de gros caches. Ce qui fait que les processeurs modernes implémentent des variantes du LRU, moins couteuses en transistors, qui donnent un résultat approximativement semblable au LRU. En clair, ils ne sélectionnent pas toujours la ligne de cache la moins récemment utilisée, mais une ligne de cache parmi les moins récemment utilisées. Ce n'est pas un problème si grave que cela car les lignes les moins récemment utilisées ont toutes assez peu de chance d'être utilisées dans le futur. Entre choisir de remplacer une ligne qui a 0,5 % de chances d'être utilisée dans le futur et une autre qui a une chance de seulement 1 %, la différence est négligeable en termes de taux de succès. Mais les gains en termes de circuits ou de temps d'accès au cache de ces algorithmes sont très intéressants. L'algorithme le plus simple consiste à couper le cache (ou chaque voie s'il est associatif) en plusieurs sections. L'algorithme détermine la section la moins récemment utilisée, avant de choisir aléatoirement une ligne de cache dans cette section. Pour implémenter cet algorithme, il nous suffit d'un registre qui mémorise le morceau le moins récemment utilisé, et d'un circuit qui choisit aléatoirement une ligne de cache. Cette technique s'adapte particulièrement bien avec des caches associatifs à voies : il suffit d'utiliser autant de morceaux que de voies. Autre algorithme, un peu plus efficace : le '''pseudo-LRU de type M'''. Cet algorithme attribue un bit à chaque ligne de cache, bit qui sert à indiquer de façon approximative si la ligne de cache associée est une candidate pour un remplacement ou non. Il vaut 1 si la ligne n'est pas une candidate pour un remplacement et zéro sinon. Le bit est mis à 1 lorsque la ligne de cache associée est lue ou écrite. Évidemment, au fil du temps, toutes les lignes du cache finiront par avoir leur bit à 1. Lorsque cela arrive, l'algorithme remet tous les bits à zéro, sauf pour la dernière ligne de cache accédée. L'idée derrière cet algorithme est d'encercler la ligne de cache la moins récemment utilisée au fur et à mesure des accès. L'encerclement commence lorsque l'on remet tous les bits associés aux lignes de cache à 0, sauf pour la ligne accédée en dernier. Au fur et à mesure des accès, l'étau se resserre autour de la ligne de cache la moins récemment utilisée. Après un nombre suffisant d'accès, l'algorithme donne une estimation particulièrement fiable. Et comme les remplacements de lignes de cache sont rares comparés aux accès aux lignes, cet algorithme finit par donner une bonne estimation avant qu'on ait besoin d'effectuer un remplacement. Le dernier algorithme d'approximation, le '''PLURt''', se base sur ce qu'on appelle un arbre de décision. Il a besoin de n − 1 bits pour déterminer la ligne LRU. Ces bits doivent être organisés en arbre, comme illustré plus bas. Chacun de ces bits sert à dire : le LRU est à ma droite ou à ma gauche : il est à gauche si je vaux 0, et à droite si je vaux 1. Trouver le LRU se fait en traversant cet arbre, et en interprétant les bits un par un. Au fur et à mesure des lectures, les bits sont mis à jour dans cet arbre, et pointent plus ou moins bien sur le LRU. La mise à jour des bits s'effectue lors des lectures et écritures : quand une ligne est lue ou écrite, elle n'est pas la ligne LRU. Pour l'indiquer, les bits à 1 qui pointent vers la ligne de cache sont mis à 0 lors de la lecture ou écriture. {| |[[File:Organisation des bits avec l'algorithme PLURt.jpg|vignette|Organisation des bits avec l'algorithme PLURt.]] |[[File:Ligne de cache pointée par les bits de l'algorithme.png|vignette|Ligne de cache pointée par les bits de l'algorithme.]] |} ===LRU amélioré=== L'algorithme LRU, ainsi que ses variantes approximatives, sont très efficaces tant que le programme respecte relativement bien la localité temporelle. Par contre, Le LRU se comporte assez mal dans les circonstances ou la localité temporelle est mauvaise mais où la localité spatiale est respectée, le cas le plus emblématique étant le parcours d'un tableau. Pour résoudre ce problème, des variantes du LRU existent. Une variante très connue, l''''algorithme 2Q''', utilise deux caches : un cache FIFO pour les données accédées une seule fois et un second cache LRU. Évidemment, les données lues une seconde fois sont migrées du cache FIFO vers le cache LRU, ce qui n'est pas très pratique. Les processeurs n'utilisent donc pas cette technique, mais celle-ci est utilisée dans les caches de disque dur. D'autres variantes du LRU combinent plusieurs algorithmes à la fois et vont choisir lequel de ces algorithmes est le plus adapté à la situation. Notre cache pourra ainsi détecter s’il vaut mieux utiliser du MRU, du LRU, ou du LFU suivant la situation. ==Les écritures dans le cache : gestion et optimisations== Les écritures se font à une adresse mémoire bien précise, qui peut ou non être chargée dans le cache. Si la donnée à écrire est chargée dans le cache, elle est modifiée directement dans le cache, mais elle ne l'est pas forcément en mémoire RAM. Suivant le processeur, les écritures sont ou non propagées en mémoire RAM. Il existe deux stratégies d'écritures, appelées respectivement le ''write-back'' et le ''write-through''. Avec un cache ''write-back'', si la donnée à mettre à jour est présente dans le cache, on écrit dans celui-ci sans écrire dans la mémoire RAM. Dans ces conditions, une donnée n'est enregistrée en mémoire que si celle-ci quitte le cache, ce qui évite de nombreuses écritures mémoires inutiles. [[File:Cache write-through.png|centre|vignette|upright=2|Cache write-through.]] Avec les caches '''Write-Through''', toute écriture dans le cache est propagée en RAM. Cette stratégie augmente le nombre d'écritures dans la mémoire RAM, ce qui peut saturer le bus reliant le processeur à la mémoire. Les performances de ces caches sont donc légèrement moins bonnes que pour les caches ''write back''. Par contre, ils sont utiles dans les architectures avec plusieurs processeurs, comme nous le verrons dans les chapitres sur les architectures multiprocesseurs. [[File:Cache write-back.png|centre|vignette|upright=2|Cache write-back.]] ===Les caches ''Write-through''=== Sans optimisation particulière, on ne peut écrire dans un cache ''write-through'' pendant qu'une écriture en RAM a lieu en même temps : cela forcerait à effectuer deux écritures simultanées, en comptant celle imposée par l'écriture dans le cache. Pour éviter cela, certains caches ''write-through'' intègrent un '''tampon d’écriture''', qui sert de file d'attente pour les écritures en RAM. C'est une mémoire FIFO dans laquelle on place temporairement les données à écrire en RAM, où elles attendent en attendant que la RAM soit libre. Grâce à lui, le processeur peut écrire dans un cache même si d'autres écritures sont en attente dans le tampon d'écriture. Par souci d'efficacité, des écritures à la même adresse en attente dans le tampon d’écriture sont fusionnées en une seule. Cela fait un peu de place dans le tampon d’écriture, et lui permet d'accumuler plus d'écritures avant de devoir bloquer le cache. Il est aussi possible de fusionner des écritures à adresses consécutives de la mémoire en une seule écriture en rafales. Dans les deux cas, on parle de '''combinaison d'écriture'''. Mais la technique du tampon d'écriture a cependant un léger défaut qui se manifeste dans une situation bien précise : quand le processeur veut lire une donnée en attente dans le tampon d’écriture. La première manière de gérer cette situation est de mettre en attente la lecture tant que la donnée n'a pas été écrite en mémoire RAM. On peut aussi lire la donnée directement dans le tampon d'écriture, cette optimisation portant le nom de '''''store-to-load forwading'''''. Dans tous les cas, il faut détecter le cas où une lecture accède à une donnée dans le tampon d'écriture. À chaque lecture, l'adresse à lire est envoyée au tampon d'écriture, qui vérifie si une écriture en attente se fait à cette adresse. Pour cela, le tampon d’écriture doit être un cache, dont chaque entrée mémorise une écriture. Chaque ligne de cache contient la donnée à écrire, et le tag de la ligne de cache contient l'adresse où écrire la donnée. Notons que cache d'écriture a une politique de remplacement de type FIFO, le tampon d'écriture non-optimisé étant une mémoire FIFO. ===Les caches ''Write-back''=== Les caches ''write-back'' ont beau avoir des performances supérieures à celles des caches ''write-through'', il existe des optimisations qui permettent d'améliorer leurs performances. Ces optimisations consistent à ajouter des caches spécialisés à côté du cache proprement dit. Ces caches permettent de mémoriser des données qui sont éliminées du cache par les algorithmes de remplacement de ligne cache, sans pour autant faire une écriture en RAM. En suivant la procédure habituelle de remplacement des lignes de cache, on doit rapatrier la ligne en RAM avant d'en charger une nouvelle. On peut améliorer la situation en faisant l'inverse : on charge la nouvelle ligne pendant que l'ancienne donnée est rapatriée en RAM. Ainsi, la nouvelle ligne est disponible plus tôt pour le processeur, diminuant son temps d'attente. Pour implémenter cette technique, on doit mémoriser l'ancienne ligne de cache temporairement dans un '''cache d’éviction''' (ou ''write-back buffer''). [[File:Cache d’éviction.png|centre|vignette|upright=2|Cache d’éviction]] Les caches directement adressés ou associatifs par voie possèdent aussi un tampon d’écriture amélioré. Pour limiter les défauts par conflit de ces caches, des scientifiques ont eu l'idée d'insérer un cache pour stocker les données virées du cache. En faisant ainsi, si une donnée est virée du cache, on peut alors la retrouver dans ce cache spécialisé. Ce cache s'appelle le '''cache de victime'''. Ce cache de victime est géré par un algorithme de suppression des lignes de cache de type FIFO. Petit détail : ce cache utilise un tag légèrement plus long que celui du cache directement adressé au-dessus de lui. L'index de la ligne de cache doit en effet être contenu dans le tag du cache de victime, pour bien distinguer deux adresses différentes, qui iraient dans la même ligne du cache juste au-dessus. [[File:Victim Cache Implementation Example.svg|centre|vignette|upright=1|Cache de victime.]] ===La configuration du fonctionnement du cache=== Sur de nombreux processeurs, il est possible de configurer la mémoire cache pour qu'elle fonctionne soit en mode ''write-back'', soit en mode ''write-through''. Pour cela, les processeurs modernes incorporent des '''registres de configuration du cache'''. Le terme ''registre de configuration du cache'' est assez transparent et indique bien quel est leur rôle. Ils configurent comment le cache est utilisé et permettent notamment de configurer le cache pour dire s'il doit fonctionner en mode ''write-back'' ou ''write-through''. Ils permettent aussi d'activer ou de désactiver la combinaison sur écriture. Les registres en question sont configurés soit par le BIOS, soit par le système d'exploitation. Ce sont des registres protégés, que les applications ne peuvent pas configurer, elles n'en ont pas le droit. Typiquement, ils ne sont accessibles en écriture qu'en mode noyau. Sur les processeurs x86, les registres de configuration du cache sont appelés des '''''Memory type range registers''''' (''MTRRs''). Les MTRRs sont assez nombreux, et il y a notamment une différence entre mode réel et protégé. Si vous vous souvenez des chapitres sur le mode d'adressage et la mémoire virtuelle, vous vous souvenez que les processeurs x86 incorporent plusieurs modes de fonctionnement. En mode réel, le processeur ne peut adresser qu'un mébioctet de RAM, avec un système de segmentation particulier. En mode protégé, le processeur peut adresser toute la mémoire et la segmentation fonctionne différemment, quand elle n'est pas simplement désactivée. Les MTRRs sont séparés en deux : ceux pour le mode réel, ceux pour le mode protégé. Les MTRRs fixes sont ceux qui configurent le cache en mode réel, ils étaient utilisés pour gérer l'accès au BIOS, à la mémoire VGA de la carte graphique, et quelques autres accès aux entrées-sorties basiques gérées nativement par le BIOS. Pour le mode protégé, les processeurs au-delà du 386 incorporent des MTRRs variables, qui servent pour les autres entrées-sorties en général, notamment les périphériques PCI, la mémoire vidéo de la carte graphique, et j'en passe. De nos jours, les registres de configuration du cache sont désuets et cette fonctionnalité est gérée directement par la mémoire virtuelle. La table des pages contient, pour chaque page mémoire, des bits de contrôle qui disent si la page mémoire est cacheable ou non. Le contournement de cache est alors géré par le système de mémoire virtuelle, le cache de TLB et tout ce qui va avec. ===L’allocation sur écriture=== Que faire quand une écriture modifie une donnée qui n'est pas dans le cache ? Doit-on écrire la donnée dans le cache, ou non ? Si la donnée est écrite dans le cache, on dit que le cache fait une '''allocation sur l'écriture''' (ou ''write-allocate''). Certains caches effectuent une telle allocation sur écriture, mais d'autres ne le font pas ou du moins pas systématiquement. L’allocation sur écriture peut se décliner en deux sous-catégories : le '''chargement à la demande''' et l''''écriture immédiate'''. Dans le premier cas, on charge la donnée à modifier dans le cache, et on la remplace avec la donnée écrite. Dans l'écriture immédiate, l'écriture a lieu directement dans le cache et la donnée à modifier n'est pas chargée dans le cache. Évidemment, seule une portion de la ligne de cache contient la donnée écrite (valide), et le reste contient des données invalides. Le cache doit savoir quelles sont les portions du cache qui sont valides : cela demande d'utiliser un ''sector cache''. [[File:Write-back with write-allocation.svg|centre|vignette|upright=2|Cache Write-back avec allocation sur écriture.]] Sans allocation sur écriture, l'écriture est transférée directement aux niveaux de cache inférieurs ou à la mémoire si la donnée à modifier n'est pas dans le cache. Certains caches de ce genre utilisent une petite optimisation : lors de toute écriture, ils supposent que l'écriture donnera un succès de cache. Si c'est le cas, la ligne de cache qui contient la donnée est mise à jour avec la donnée à écrire. Mais si ce n'est pas le cas, la ligne de cache est invalidée, et l'écriture est transférée directement à la mémoire ou aux niveaux de cache inférieurs. [[File:Write-through with no-write-allocation.svg|centre|vignette|upright=2|Cache Write-through sans allocation sur écriture.]] ===La cohérence des caches=== Il arrive parfois que la mémoire d'un ordinateur soit mise à jour, sans que les modifications soient répercutées dans les mémoires cache. Dans ce cas, le cache contient une donnée périmée. Or, un processeur doit toujours éviter de se retrouver avec une donnée périmée et doit toujours avoir la valeur correcte dans ses caches : cela s'appelle la '''cohérence des caches'''. Il est possible de se retrouver avec des valeurs périmées dans le cache sur les ordinateurs avec plusieurs processeurs, ou si un périphérique écrit en RAM, les modifications ne sont pas répercutées automatiquement dans les mémoires cache. Pour résoudre ce problème, on peut interdire de charger dans le cache des données stockées dans les zones de la mémoire dédiées aux périphériques. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires cache. Autre solution : utiliser le fait que les périphériques déclenchent une interruption matérielle pour laisser le contrôleur DMA accéder à la mémoire. Dans ce cas, il suffit de vider les caches à chaque interruption matérielle. Le processeur peut le faire automatiquement, ou fournir des instructions pour. ==Le ''cache bypassing'' : contourner le cache== Dans certaines situations, le cache n'est pas utilisé pour certains accès mémoire. Diverses techniques permettent en effet d'effectuer des accès mémoire qui contournent le cache, qui ne passent pas par le cache. Ils sont utilisés quand l'accès en cache fait que des instructions normales ne fonctionnent pas. Par exemple, de tels accès directs à la RAM sont notamment utilisés pour l'implémentation d'instructions atomiques, une classe d'instructions spécifiques utilisées sur les processeurs multicœurs, dont nous parlerons dans plusieurs chapitres. Mais ils sont aussi utilisés pour l'accès aux périphériques, ce que nous allons voir maintenant. ===Accéder aux périphériques demande de contourner le cache=== Pour rappel, un périphérique (au sens d'entrée-sortie) contient des registres d’interfaçage qui ont une adresse au même titre que les cases mémoire. Un périphérique peut à tout instant modifier ses registres d’interfaçage, ce qui se répercute automatiquement dans l'espace d'adressage, mais rien de tout cela n'est transmis au cache. Si les accès aux périphériques passaient par l'intermédiaire du cache, on aurait droit à des problèmes. On aurait encore une fois droit à des problèmes de cohérence des caches. Le problème est géré différemment suivant que l'on utilise un espace d'adressage séparé ou des entrées-sorties mappées en mémoire. La solution est que les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache. Cela demande d'adapter le cache et le processeur. L'implémentation exacte dépend de comment sont adressés les périphériques. Pour rappel, il y a deux solutions pour adresser les périphériques : soit les périphériques disposent d'un espace d'adressage séparé de celui de la mémoire, soit il y un espace d'adressage unique partagé entre processeur et mémoire. Les deux cas donnent des solutions différentes. Avec un espace d'adressage séparé, l'espace d'adressage des périphériques n'est pas caché : aucun accès dans cet espace d'adressage ne passe par le cache. La mémoire cache n'est utilisée que pour l'espace d'adressage des mémoires, rien d'autre. C'est de loin le cas le plus simple : il suffit de concevoir le processeur pour. Il dispose d'instructions séparées pour les accès aux registres d’interfaçage et à la RAM/ROM, les premières ne passent pas par le cache, les autres si. Avec des entrées-sorties mappées en mémoire, la même solution est utilisée, mais dans une version un peu différente. Là encore, les accès aux périphériques ne doivent pas passer par l’intermédiaire du cache, si on veut qu'ils marchent comme ils le doivent. Cela demande d'adapter le cache et le matériel pour que accès aux périphériques mappés en mémoire contournent le cache. Des adresses, voire des zones entières de la mémoire, sont marquées comme étant non-cachables. Toute lecture ou écriture dans ces zones de mémoire ira donc directement dans la mémoire RAM, sans passer par la ou les mémoires caches. Là encore, le processeur doit être prévu pour : on doit pouvoir le configurer de manière à marquer certaines zones de la RAM comme non-cacheable. Reste qu'il faut marquer des régions de la RAM comme non-cacheable. Pour cela, on améliore les registres de configuration du cache, vus plus haut, afin qu'ils permettent de configurer certaines portions de la RAM pour préciser qu'elles ne doivent pas être mises en cache, qu'il faut activer le contournement de cache pour celles-ci. ===Contourner le cache pour des raisons de performance=== Il arrive que des données avec une faible localité soient chargées dans le cache inutilement. Or, il vaut mieux que ces données transitent directement entre le processeur et la mémoire, sans passer par l'intermédiaire du cache. Pour cela, le processeur peut fournir des instructions d'accès mémoire qui ne passent pas par le cache, à côté d'instructions normales. De telle instructions sont appelées des '''instructions mémoire non-temporelles'''. Non-temporelle, dans le sens : pas de localité temporelle (c.a.d que les données ne seront pas réutilisées plus tard). Mais il existe aussi des techniques matérielles, où le cache détecte à l'exécution les lectures qui gagnent à contourner le cache. La dernière méthode demande d'identifier les instructions à l'origine des défauts de cache, le processeur accédant directement à la RAM quand une telle instruction est détectée. Si une instruction d'accès mémoire fait trop de défauts de cache, c'est signe qu'elle gagne à contourner le cache. L'idée est de mémoriser, pour chaque instruction d'accès mémoire, un historique de ses défauts de cache. Il existe plusieurs méthodes pour cela, mais toutes demandent d'ajouter de quoi mémoriser l'historique des défauts de cache des instructions. L'historique est mémorisé dans une mémoire appelée la '''table d’historique des défauts de lecture''' (''load miss history table''), qui est souvent un cache. L'historique en question est, dans sa version la plus simple, un compteur de quelques bits incrémenté à chaque succès de cache et décrémenté à chaque défaut de cache, qui indique si l'instruction a en moyenne fait plus de défauts ou de succès de cache. La table associe le ''program counter'' d'une instruction mémoire à cet historique. À la première exécution d'une instruction d'accès mémoire, une entrée de cette table est réservée pour l'instruction. Lors des accès ultérieurs, le processeur récupérer les informations associées et décide s'il faut contourner le cache ou non. ==La hiérarchie mémoire des caches== [[File:Cache Hierarchy.png|vignette|Hiérarchie de caches]] On pourrait croire qu'un seul cache est largement suffisant pour compenser la lenteur de la mémoire. Hélas, les processeurs sont devenus tellement rapides que les caches sont eux-mêmes très lents ! Pour rappel, plus une mémoire peut contenir de données, plus elle est lente. Et les caches ne sont pas épargnés. Si on devait utiliser un seul cache, celui-ci serait très gros et donc trop lent. La situation qu'on cherche à éviter avec la mémoire RAM revient de plus belle. Même problème, même solution : si on a décidé de diviser la mémoire principale en plusieurs mémoires de taille et de vitesse différentes, on peut bien faire la même chose avec la mémoire cache. Depuis environ une vingtaine d'années, un processeur contient plusieurs caches de capacités très différentes : les caches L1, L2 et parfois un cache L3. Certains de ces caches sont petits, mais très rapides : c'est ceux auxquels on va accéder en priorité. Viennent ensuite d'autres caches, de taille variable, mais plus lents. Les processeurs ont donc une hiérarchie de caches qui se fait de plus en plus complexe avec le temps. Cette hiérarchie est composée de plusieurs niveaux de cache, qui vont des niveaux inférieurs proches de la mémoire RAM à des niveaux supérieurs proches du processeur. Plus on monte vers les niveaux supérieurs, plus les caches sont petits et rapides. Un accès mémoire dans une hiérarchie de cache fonctionne comme suit : on commence par vérifier si la donnée recherchée est dans le cache le plus rapide, à savoir le cache L1. Si c'est le cas,n on la charge depuis ce cache directement. Si elle n’y est pas, on vérifie si elle est dans le cache de niveau supérieur, le cache L2. Et rebelote ! Si elle n'y est pas, on vérifie le cache du niveau supérieur. Et on répète cette opération, jusqu’à avoir vérifié tous les caches. Si la donnée n'est dans aucun cache, on doit alors aller chercher la donnée en mémoire. [[File:Hiérarchie de caches.png|centre|vignette|upright=2|Hiérarchie de caches]] Il y a des différences assez notables entre chaque niveau de cache. Par exemple, les différents niveaux de cache n'ont pas forcément les mêmes politiques de remplacement des lignes de cache. Le cache L1 a généralement une politique de remplacement simple, très rapide, mais peu efficace. De même, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Le cache le plus proche de la mémoire est appelé le '''cache de dernier niveau''', ''Last Level Cache'' en anglais. Il a parfois des caractéristiques totalement différentes des autres caches. Par exemple, sur les processeurs multicoeurs, le cache L3 n'est techniquement pas dans le cœur, mais fait partie d'un ensemble de circuits reliés, comme le contrôleur mémoire ou l'interface mémoire. Il fonctionne à une fréquence différente du processeur, n'a pas la même tension d'alimentation, etc. ===Les caches exclusifs et inclusifs=== Notons que du point de vue de cette vérification, il faut distinguer les caches inclusifs et exclusifs. Avec les caches inclusifs, si une donnée est présente dans un cache, alors elle est présente dans les caches des niveaux inférieurs, ce qui implique l'existence de données en doublon dans plusieurs niveaux de cache. À l'opposé, les caches exclusifs font que toute donnée est présente dans un seul cache, pas les autres. Il existe aussi des caches qui ne sont ni inclusifs, ni exclusifs. Sur ces caches, chaque niveau de cache gère lui-même ses données, sans se préoccuper du contenu des autres caches. Pas besoin de mettre à jour les niveaux de cache antérieurs en cas de mise à jour de son contenu, ou en cas d'éviction d'une ligne de cache. La conception de tels caches est bien plus simple. Dans les '''caches exclusifs''', le contenu d'un cache n'est pas recopié dans le cache de niveau inférieur. Il n'y a pas de donnée en double et on utilise 100 % de la capacité du cache, ce qui améliore le taux de succès. Par contre, le temps d'accès est un peu plus long. La raison est que si une donnée n'est pas dans le cache L1, on doit vérifier l'intégralité du cache L2, puis du cache L3. De plus, assurer qu'une donnée n'est présente que dans un seul cache nécessite aux différents niveaux de caches de communiquer entre eux pour garantir que l'on a pas de copies en trop d'une ligne de cache, ce qui peut prendre du temps. [[File:Caches exclusifs.png|centre|vignette|upright=2|Caches exclusifs]] Dans le cas des '''caches inclusifs''', le contenu d'un cache est recopié dans les caches de niveau inférieur. Par exemple, le cache L1 est recopié dans le cache L2 et éventuellement dans le cache L3. Ce genre de cache a un avantage : le temps d'accès à une donnée est plus faible. La raison est qu'il ne faut pas vérifier tout un cache, mais seulement la partie qui ne contient pas de donnée en doublon. Par exemple, si la donnée voulue n'est pas dans le cache L1, on n'est pas obligé de vérifier la partie du cache L2 qui contient la copie du L1. Ainsi, pas besoin de vérifier certaines portions du cache, ce qui est plus rapide et permet de simplifier les circuits de vérification. En contrepartie, l'inclusion fait que qu'une partie du cache contient des copies inutiles, comme si le cache était plus petit. De plus, maintenir l'inclusion est compliqué et demande des circuits en plus et/ou des échanges de données entre caches. [[File:Caches inclusifs.png|centre|vignette|upright=2|Caches inclusifs]] Maintenir l'inclusion demande de respecter des contraintes assez fortes, ce qui ne se fait pas facilement. Premièrement, toute donnée chargée dans un cache doit aussi l'être dans les caches de niveau inférieur. Ensuite, quand une donnée est présente dans un cache, elle doit être maintenue dans les niveaux de cache inférieurs. De plus, toute donnée effacée d'un cache doit être effacée des niveaux de cache supérieurs : si une donnée quitte le cache L2, elle doit être effacée du L1. Ces trois contraintes posent des problèmes si chaque cache décide du remplacement des lignes de cache en utilisant un algorithme comme LRU, LFU, MRU, ou autre, qui utilise l'historique des accès. En effet, dans ce cas, le cache décide de remplacer les lignes de cache selon l'historique des accès, historique qui varie suivant chaque niveau de cache. Par exemple, une donnée rarement utilisée dans le L2 peut parfaitement être très fréquemment utilisée dans le L1 : la donnée sera alors remplacée dans le L2, mais sera maintenue dans le L1. On observe aussi des problèmes quand il existe plusieurs caches à un seul niveau : chaque cache peut remplacer les lignes de cache d'une manière indépendante des autres caches du même niveau, donnant lieu au même type de problème. Pour maintenir l'inclusion, les caches doivent se transmettre des informations qui permettent de maintenir l'inclusion. Par exemple, les caches de niveaux inférieurs doivent prévenir les niveaux de cache supérieurs quand ils remplacent une ligne de cache. De plus, toute mise à jour dans un cache doit être répercutée dans les niveaux de cache inférieurs et/ou supérieurs. On doit donc transférer des informations de mise à jour entre les différents niveaux de cache. Généralement, le contenu des caches d'instruction n'est pas inclus dans les caches de niveau inférieurs, afin d'éviter que les instructions et les données se marchent sur les pieds. Enfin, il faut aussi savoir que la taille des lignes de cache n'est pas la même suivant les niveaux de cache. Par exemple, le L2 peut avoir des lignes plus grandes que celles du L1. Dans ce cas, l'inclusion est plus difficile à maintenir, pour des raisons assez techniques. ===Les caches eDRAM, sur la carte mère et autres=== D'ordinaire, les mémoires caches sont intégrées au processeur, à savoir que cache et CPU sont dans le même circuit imprimé. Les caches sont donc fabriqués avec de la SRAM, seule forme de mémoire qu'on peut implémenter dans un circuit intégré. Intégrer tous les caches dans le processeur est une solution et efficace. Mais certains processeurs ont procédé autrement. [[File:Cache-on-a-stick module.jpg|vignette|Cache-on-a-stick module]] Des processeurs assez anciens incorporaient un cache L1 dans le processeur, mais plaçaient un cache L2 sur la carte mère. Le cache était clippé sur un connecteur sur la carte mère, un peu comme le sont les barrettes de mémoire. On parlait alors de '''''Cache on a stick''''' (COAST). On aurait pu s'attendre à ce que de tels caches soient en DRAM, vu qu'ils sont placés sur des barrettes de RAM, mais la ressemblance avec la mémoire RAM principale s'arrête là. Le cache était fabriqué en mémoire SRAM, même s'il est en théorie possible de faire de tels caches avec de la DRAM. Les premiers processeurs avec un cache faisaient ainsi, au début des années 90. Il a été introduiot sur les processeurs Motorola, et a été utilisé sur les IBM PC et les Macintosh de l'époque. Les ordinateurs Macintosh utilisaient de tels caches, pour la pluaprt des modèles. Pour ce qui est des PC, les premiers processeurs x86 faisaient pareil, notamment les processeurs Intel. Le 486, le Pentium et le Pentium 2 utilisaient des ''Cache on a stick''. L'avantage est que cela permettait de mettre plus de cache, à une époque où les circuits étaient limités en transistors. De plus, cela permettait au consommateur de choisir quelle quantité de cache il voulait, selon ses finances. Il était possible de laisser le processeur fonctionner soit sans mémoire cache, soit avec un cache de 256 Kibioctets, de 512 Kibioctets, etc. Il était possible d'upgrader le cache si besoin. Pour les CPU Intel, le cache était connecté sur le bus système, au même titre que la mémoire RAM et les entrées-sorties. Il faut dire que les processeurs de l'époque utilisaient un bus système et n'avaient pas de bus mémoire dédié. Mais en théorie, rien n’empêche de connecter le cache sur un bus mémoire dédié. Toujours est-il que les lectures et écritures étaient propagées à la fois dans le cache et la RAM. Les écritures se faisaient dans les deux, systématiquement dans la RAM, mais aussi dans le cache en cas de succès de cache. Les lectures étaient servies soit par le cache en cas de succès de cache, soit par la RAM en cas de défaut de cache. Si le cache répondait en premier, la transaction sur le bus se terminait précocement et l'accès en RAM était abandonné. [[File:Intel486 Иерархия памяти.png|centre|vignette|upright=2.5|Intel486 : le cache était connecté sur le bus système.]] À l'inverse, certains processeurs possédaient un cache fabriqué en mémoire DRAM, et plus précisément avec de la mémoire eDRAM. Le cache n'était pas intégré dans le même circuit imprimé que le processeur, mais profitait d'une architecture en ''chiplet''. Pour rappel, cela veut dire que le processeur est en réalité composé de plusieurs circuits intégré séparés, mais interconnectés et soudés sur un même PCB carré. Avec un cache en eDRAM, le cache avait son propre circuit intégré, séparé du circuit intégré du processeur ou du circuit intégré pour le contrôleur mémoire/IO. Un exemple est celui du cache des processeurs Intel de microarchitecture Broadwell, que nous allons voir dans la section suivante. ==Les caches splittés (''phased caches'')== Dans cette section, nous allons voir les '''caches splittés''' (''phased caches''), dans lequel le cache est accédé en deux étapes consécutives. Il ne s'agit pas des caches pipelinés, que nous verrons dans le chapitre sur les processeurs pipélinés, mais laissons cela à plus tard. Nous allons partir du principe que ce sont des caches ''direct-mapped'', mais l'optimisation peut être utilisée sur un cache associatif par voie. L'idée est de scinder le cache en deux : une mémoire pour les tags, une autre pour les données de la ligne de cache. Les bits de contrôle peuvent être mis dans l'une ou l'autre SRAM, mais ils sont souvent mis dans la RAM pour les tags. En faisant cela, quelques optimisations deviennent possibles, afin de réduire la consommation énergétique en contrepartie d'une perte de performance. La technique s'implémente différemment pour les caches totalement associatifs et partiellement associatifs. Les caches totalement associatifs splittés sont ceux formés en combinant un cache associatif avec une CAM et une RAM combinée. On envoie l'adresse à lire/écrire à la mémoire associative, elle répond en envoyant une adresse à la mémoire RAM. L'accès se fait donc en deux temps, avec l'adresse dans la RAM comme intermédiaire. Il est possible de séparer physiquement les deux étapes en insérant un registre entre la CAM et la RAM, ce qui permet aussi de pipeliner l'accès. Mais c'est rarement fait en pratique, car le cout en circuit d'une mémoire CAM est trop important. L'équivalent pour un cache totalement associatif optimisé, sans CAM et RAM séparée, est trop gourmande en interconnexions pour être implémentée. Les caches totalement associatifs splittés sont donc très rares, l'auteur ne connait aucun exemple de processeur avec un tel cache. Il existe une technique équivalente pour les caches ''direct-mapped'', mais elle demande une certaine modification du cache. Dans les caches ''direct-mapped'' non-splittés, on trouve une mémoire SRAM dont chaque mot mémoire contient une ligne de cache entière, tag inclus. Dans leurs versions splittés, la SRAM est séparée en deux : une pour les tags, une autre pour les données. Précisons qu'il s'agit bien de deux mémoires SRAM adressables. L'adresse à laquelle accéder est envoyée à la SRAM des tags, puis ensuite à la SRAM des données si besoin. L'idée est d’accéder aux tags pour déterminer s'il y a un succès de cache ou un défaut, et ensuite d'accéder aux données. On n’accède pas aux données en parallèle des tags. Faire cela est évidemment plus lent. En cas de défaut de cache, le temps d'accès est similaire : le tag ne correspond pas, on n'accède pas à la SRAM pour les données. Par contre, vu qu'on n'a pas activé la SRAM pour les données, on économise un peu d'énergie, ce qui réduit la consommation d'énergie. En cas de succès de cache, on accède à la SRAM pour les tags, puis à celle pour les données. Pas d'économie d'énergie à l'horizon, sans compter que le temps d'accès augmente : on accède au cache en deux étapes au lieu de faire les deux accès en parallèle. [[File:Phased cache.png|centre|vignette|upright=1.5|Phased cache]] Précisons cependant que ce design peut avoir deux avantages en termes de performance. Premièrement, le temps d'accès au cache est légèrement amélioré en cas de défaut de cache. En effet, la SRAM des tags est assez petite, idem pour celle des données. Leur temps d'accès est donc plus faible que pour une grosse SRAM contenant données et tags. Le gain en temps d'accès est donc un avantage, qui ne se manifeste surtout en cas de défaut de cache. Un autre avantage est que l'accès au cache se pipeline plus facilement, ce qui fait qu'on peut effectuer plusieurs accès simultanés au cache. Mais nous verrons cela dans quelques chapitres. ===Le contrôleur de cache 82385 pour les CPU Intel 386=== Il est important de noter que la séparation entre tags et RAM peut être telle que les deux ne sont pas sur la même puce de silicium ! Voire que les deux sont séparés du processeur ! C'était le cas quand les mémoires caches ont été introduites sur les processeurs grand public, notamment sur les premiers processeurs Intel. La miniaturisation n'avait pas avancé au point où placer un cache dans le processeur était possible. Sur le processeur 386 d'Intel, le cache était un cache splitté, séparé du processeur. Concrètement, le processeur i386 était couplé à un contrôleur de cache Intel 82385 et une mémoire SRAM. Le 82385 contenait les ''tags'' et les bits de contrôle, la SRAM contenait les données, les lignes de cache. Un point important est que les lignes de cache faisaient seulement 32 bits/4 octets, pas plus ! On était loin des lignes de cache actuelles, faisant 64 octets/512 bits. Mais c'était beaucoup plus pratique, vu que le bus système faisait 32 bits de large, idem pour l'interface avec le processeur. Pour intégrer un cache facultatif (certains ordinateurs s'en passaient). Le schéma ci-dessous montre comment le cache s'intégrait avec le bus système. Pour le bus de commande, le cache servait d'intermédiaire : il recevait les commandes et les filtrait suivant les succès/défauts de cache. En cas de succès de cache, les commandes de lecture n'étaient pas envoyées à la mémoire RAM. Les adresses étaient transmises à la fois au cache et au bus système (avec un registre entre le bus système et le processeur). Le bus de donnée était lui connecté à la mémoire SRAM et au processeur, avec des MUX/DEMUX pour faire le choix de la source des lectures. [[File:Controleur de cache 82385 pour l'Intel 386.png|centre|vignette|upright=2.5|Contrôleur de cache 82385 pour l'Intel 386]] Le 82385 surveillait ce qui se passait sur le bus et répondait à la place de la RAM pour certaines lectures. C'était un intermédiaire assez passif, qui se contenait de répondre aux succès et défauts en lecture. Le cache était un cache ''write through'' un peu particulier. En cas de succès de cache pour une écriture, le cache met à jour sa ligne de cache et propage l'écriture en mémoire RAM. Par contre, si une écriture fait un défaut de cache, la donnée n'est pas écrite dans le cache. Le seul moyen pour copier une donnée dans le cache était un défaut pour une lecture. Le 82385 pouvait commander soit un cache ''direct mapped'', soit associatif à deux voies. Le choix entre les deux était le fait d'une entrée : la mettre à 0 indiquait un cache ''direct mapped'', la mettre à 1 forçait un cache à deux voies. La différence entre les deux est que le 82385 était relié à une mémoire SRAM avec un cache ''direct mapped'', deux SRAM pour deux voies. Pour avoir un cache associatif à deux voies, le 82385 devrait gérer deux signaux ''chip select'' pour activer chaque SRAM/voie suivant les besoins. Il avait précisément quatre signaux CS : deux par SRAM, un pour les lectures, un pour les écritures. Notons que les lignes de cache faisaient 32 bits, ce qui pouvait d'obtenir soit avec une SRAM 32 bits, soit avec deux SRAM 16 bits, soit avec 4 SRAM 8 bits. Le 82385 rajoutait 4 sorties, pour masquer chaque octet dans ces 32 bits, qui sont techniquement des signaux ''Output Enable'' pour 4 SRAM 8 bits. [[File:Interface entre le 82385 et la SRAM du cache.png|centre|vignette|upright=2|Interface entre le 82385 et la SRAM du cache. Beaucoup d'entrées et de sorties liées au bus d'adresse ne sont pas représentées.]] Il gérait aussi les accès mémoire non-cacheable, à savoir des accès mémoire qui ne doivent pas être pris en compte par le cache. Il considérait certains accès mémoire comme "à ne pas cacher". Notamment, les accès mémoire à une entrée-sortie ne sont pas cachés. Pour rappel, le processeur utilisait un espace d'adressage séparé pour les entrées-sorties, et utilisait donc un bit IO, qui était utilisé par le 82385 pour savoir si l'accès mémoire doit être caché ou non. Il en est de même pour les accès ayant lieu lors d'une interruption, qui ne passent pas par le cache. Mais au-delà de cette inhibition automatique du cache, le 82385 avait une entrée NCA (''Non Cacheable Access'') : le cache était "désactivé" quand cette entrée était à 1. C'est un peu une sorte de ''chip select'' pour le 82385, limitée aux accès mémoire. Cette entrée permettait de programmer des intervalles d'adresse auxquels ne pas répondre, en utilisant des circuits de décodage d'adresse adaptés. Il avait aussi une entrée X16, qui permettait d'identifier les accès soit à un composant 16 bits. De tels accès ne doivent pas être mis en cache, sans doute parce que cela ne collait pas avec la taille des lignes de cache (32 bits). Et cette entrée permettait d'inhiber ces accès 16 bits d'agir sur le cache, en utilisant le bit du bus de commande adéquat. Le 82385 pouvait être intégré dans un système à deux processeurs, voire plus. Pour cela, chaque processeur avait son propre 82385 et sa SRAM rien qu'à lui. Il n'y avait pas de cache partagé entre les deux processeurs. Par contre, les deux caches étaient reliés au même bus système. Pour qu'ils ne se marchent pas sur les pieds, il y avait des circuits d'arbitrage pour gérer l'accès au bus. Un des deux 82385 était mis en mode maitre, l'autre était en mode esclave. Le 82385 maitre pouvait prendre le contrôle du bus, le 82385 esclave devait demander l'autorisation au premier pour accéder au bus système. Le 82385 gérait une forme limitée de cohérence des caches par invalidation. Dès que le 82385 détectait une prise de contrôle du bus par autre chose que le processeur, il surveillait les adresses transmises sur le bus. En cas de succès de cache, la ligne de cache associée était invalidée. Au-delà de ça, le 82385 avait une entrée FLUSH, qui ordonnait une invalidation totale du cache. Si cette entrée est mise à 1, toutes les lignes de cache sont invalidées. Les ''tags'' sont marqués comme invalides, mais les lignes de cache elles-mêmes ne sont pas touchées. ===L'exemple des processeurs Intel de microarchitecture ''Broadwell''=== Un autre exemple est celui du cache L4 des processeurs Broadwell et de quelques processeurs séparés. Ces processeurs ont une organisation en ''chiplet'' où le processeur incorpore plusieurs puces séparées : une puce pour le processeur proprement dit, une puce nommée ''Crystal Well'' pour le cache L4, et une puce IO pour la communication avec la RAM et la carte mère. Le processeur incorporait un cache L4 de 128 mébioctets, composé de mémoire eDRAM, qui était dispersé entre ''Crystal Well'' et les autres puces. Les données du cache L4 étaient dans ''Crystal Well'', alors que les Tags étaient soit dans le processeur lui-même, soit dans la puce IO ! La puce ''Crystal Well'' était une mémoire DRAM adressable tout ce qu'il y a de plus basique, avec cependant quelques optimisations notables. Par exemple, elle avait deux bus séparés pour l'écriture et la lecture. De plus, elle avait une organisation interne avec 128 banques, contre moins d'une dizaine pour la DDR de l'époque et environ 32 banques pour la DDR5 moderne. Elle contenait aussi quelques circuits pour gérer son rôle de mémoire cache, mais rien en ce qui concerne la gestion des tags eux-mêmes. Sur les processeurs de microarchitecture ''Broadwell'', les tags étaient placés dans le CPU et précisément dans le cache L3. À chaque accès mémoire au cache L3, les tags du cache L4 étaient consultés en parallèle. De fait, l'accès au cache L4 était assez rapide, malgré le fait que les données étaient dans une puce à part. Ajoutons à cela que le processeur et ''Crystal Well'' n'avaient pas la même finesse de gravure ni la même technologie de fabrication. Les tags étaient implémentés avec de la SRAM contre la DRAM pour les données, ce qui fait que la consultation des tags était plus rapide que l'accès aux données. Par la suite, dans certains CPU de microarchitecture ''skylake'', les tags ont été déplacés en-dehors du processeur pour finir dans le contrôleur mémoire. En faisant cela, le cache L4 pouvait être utilisé par autre chose que le processeur, et notamment par la carte graphique intégrée au CPU. Avec ''broadwell'', le fait que les tags étaient consultés en cas d'accès au L3 empêchait au GPU intégré de consulter le cache L4. Mais en déplaçant les tags dans le contrôleur mémoire, ce n'est plus le cas vu que la carte graphique a aussi accès au bus mémoire. Par contre, le temps d'accès augmente comparé à la solution précédente. On n'accède pas aux tags du L4 en parallèle du L3 : à la place, il faut consulter les tags du L3, détecter un défaut de cache L3, et ensuite accèder aux tags. ===Les caches RAM-configurables=== Un autre avantage des caches splittés est qu'on peut les modifier pour servir à la fois de mémoire cache, mais aussi de ''local store'', de mémoire RAM de petite taille. Le fonctionnement est assez simple à comprendre. Lors d'un accès au cache, on accède aux tags, puis à la RAM interne au cache. Lors d'un accès au ''local store'', on contourne l'accès au tags et on accède à la RAM interne au cache directement. Il s'agit de la technique du '''cache RAM-configurable''. L'usage de cache RAM-configurable est fréquent sur les cartes graphiques récentes, qui incorporent un ou plusieurs processeurs multicoeurs, dont le cache L1 de données est un cache RAM-configurable. [[File:Hydride cache - local store.png|centre|vignette|upright=2.0|Hydride cache - local store]] ===La compression de cache=== Une autre optimisation permise par les ''phased caches'' est l'implémentation de techniques de '''compression de cache''', qui visent à compresser des lignes de cache. L'intérêt est qu'on peut stocker plus de données dans le cache, à capacité égale. L'inconvénient est qu'on doit compresser/décompresser les lignes de cache, ce qui demande un circuit en plus et allonge les temps d'accès. En effet, le temps mis pour compresser/décompresser une ligne de cache s'ajoute au temps d'accès. Aussi, la compression de cache sert surtout pour les caches de bas niveau dans la hiérarchie mémoire, les gros caches aux temps d'accès assez longs. Une première technique, assez simple à implémenter et peu couteuse en circuit, est celle de la '''compression des lignes de cache nulles'''. Elle compresse uniquement les lignes de cache qui ne contiennent que des zéros. L'idée est qu'on ajoute, dans la mémoire des tags, un bit de contrôle pour chaque ligne de cache appelé le bit ''null''. Il indique si la ligne de cache ne contient que des zéros. Quand on lit une ligne de cache, la mémoire des tags est accédée et on vérifie le bit ''null'' : s'il vaut 1, on n'accède pas à la mémoire cache de données et un multiplexeur envoie un zéro sur le port de lecture. Le bit ''null'' est fixé lors de l'écriture d'une ligne de cache : elle passe dans un comparateur avec zéro relié à la mémoire des tags. La comparaison avec zéro peut se faire en parallèle de l'écriture ou avant (dans ce cas, on n'écrit pas la ligne de cache dans le cache). Les autres techniques de compression de cache permettent de compresser autre chose que des lignes de cache nulles. L'idée est qu'une ligne de cache physique peut par moment mémoriser plusieurs lignes de caches compressées. Par exemple, prenons un cache dont les lignes de cache font 64 octets. Il est possible de compresser deux lignes de cache pour qu'elles fassent chacune 32 octets, et les stocker dans une seule ligne de cache. Les deux lignes de cache auront des tags différents, mais pointeront sur la même ligne de cache physique. Et cela demande d'utiliser un ''phased cache'' dont la mémoire pour les tags est plus grande que la mémoire pour les données. Il n'y a donc plus une bijection entre tags et ligne de cache, mais une relation surjective. Chose qui n'est possible qu'avec un ''phased cache''. De plus, des bits de contrôles associés à chaque ''tag'' indiquent où se trouvent les lignes de cache compressées dans la ligne de cache : est-ce que c'est les 32 octets de poids fort ou de poids faible ? [[File:Compression de cache.png|centre|vignette|upright=2|Compression de cache]] Il ne semble pas que les techniques de compression de cache soient implémentées sur les processeurs modernes. Aucun n'utilise de compression de cache, à ma connaissance. Il faut dire que les techniques connues sont de mauvais compromis : le temps d'accès du cache augmente beaucoup, le cout en circuit pourrait être utilisé pour un cache non-compressé mais plus grand. Et notons que la compression de cache ne marche que si les données peuvent se compresser. Si ce n'est pas le cas, une partie de la mémoire des tags est inutilisée. Une revue de la littérature académique sur la compression de cache est disponible via ce lien, pour les curieux : * [https://inria.hal.science/hal-03285041 Understanding Cache Compression, par Carvalho et Seznec]. ==Les caches adressés par somme et hashés== Les caches adressés par somme sont optimisés pour incorporer certains calculs d'adresse directement dans le cache lui-même. Pour rappel, certains modes d'adressage impliquent un calcul d'adresse, qui ajoute une constante à une adresse de base. Généralement, l'adresse de base est l'adresse d'un tableau ou d'une structure, et la constante ajoutée indique la position de la donnée dans le tableau/la structure. Les caches hashés et les caches adressés par somme permettent de faire l'addition directement dans la mémoire cache. Voyons d'abord les caches hashés, avant de passer aux caches adressés par somme. Sur les '''caches hashés''', l'addition est remplacée par une autre opération, par exemple des opérations bit à bit du style XOR, AND ou OR, etc. Seulement, utiliser des opérations bit à bit pose un problème : il arrive que deux couples Adresse/décalage donnent le même résultat. Par exemple, le couple Adresse/décalage 11101111/0001 donnera la même adresse que le couple 11110000/0000. Dit autrement, deux adresses censées être différentes (après application du décalage) sont en réalité attribuées à la même ligne de cache. Il est toutefois possible de gérer ces situations, mais cela demande des astuces de haute volée pour faire fonctionner la mémoire cache correctement. Sur les '''caches adressés par somme''', le décodeur est modifié pour se passer de l'addition. Pour comprendre comment, il faut rappeler qu'un décodeur normal est composé de comparateurs, qui vérifient si l'entrée est égale à une constante bien précise. Sur un cache ordinaire, l'addition est faite séparément du décodage des adresses par le cache, dans l'unité de calcul ou dans l'unité de génération d'adresse. [[File:Non sum adressed cache.png|centre|vignette|upright=2|Cache normal.]] Mais les caches adressés par somme modifient le décodeur, qui est alors composé de comparateurs qui testent si la somme adresse + décalage est égale à une constante. [[File:Cache adressé par somme.png|centre|vignette|upright=2|Cache adressé par somme.]] Chaque circuit du décodeur fait le test suivant, avec K une constante qui dépend du circuit : : <math>A + B = K</math> Ce qui est équivalent à faire le test suivant : : <math>A + B - K = 0</math> En complément à deux, on a <math>- K = \overline{K} + 1</math>. En injectant dans l'équation précédente, on a : : <math>A + B + \overline{K} + 1 = 0</math> En réorganisant les termes, on a : : <math>A + B + \overline{K} = - 1</math> Il suffit d'utiliser un additionneur ''carry-save'' pour faire l'addition des trois termes. Rappelons qu'un tel additionneur fournit deux résultats en sortie : une somme calculée sans propager les retenues et les retenues en question. Notons que les retenues sont à décaler d'un cran, vu qu'elles sont censées s'appliquer à la colonne suivante. En notant la somme S et les retenues R, on a: : <math>S + (R << 1) = - 1 </math>, le décalage d'un cran à gauche étant noté <math><< 1</math>. Ensuite, -1 est codé avec un nombre dont tous les bits sont à 1 en complément à un/deux. : <math>S + (R << 1) = 111 \cdots 111111</math> [[File:Sum + retenue add.png|centre|vignette|upright=2|Sum + retenue add]] Un simple raisonnement nous permet de savoir si le résultat est bien -1, sans faire l'addition <math>S + (R << 1)</math>. En effet, on ne peut obtenir -1 que si la somme est l'inverse des retenues : un 0 dans le premier nombre correspond à un 1 dans l'autre, et réciproquement. En clair, on doit avoir <math>\overline{S} = R << 1</math>. Pour vérifier cela, il suffit de faire un simple XOR entre la somme et les retenues décalées d'un cran. On a alors : : <math>S \oplus (R << 1) = 111 \cdots 111111</math> La comparaison avec -1 se fait avec une porte ET à plusieurs entrées. En effet, la porte donnera un 1 seulement si tous les bits d'entrée sont à 1, ce qui est ce qu'on veut tester. Au final, l'additionneur pour l'addition adresse + décalage est remplacé par un additionneur carry-save suivi d'une couche de portes XOR et d'un comparateur avec une constante, ce qui économise de circuits et améliore les performances. [[File:Final circuit of sum addressed cache.png|centre|vignette|upright=2|Cache adressé par somme.]] En prenant en compte que la constante K est justement une constante, certaines entrées de l'additionneur carry-save sont toujours à 0 ou à 1, ce qui permet quelques simplifications à grand coup d’algèbre de Boole. Chaque additionneur complet qui compose l’additionneur carry-save est remplacée par des demi-additionneurs (ou par un circuit similaire). Autant dire que l'on gagne tout de même un petit peu en rapidité, en supprimant une couche de portes logiques. Le circuit de décodage économise aussi des portes logiques, ce qui est appréciable. ==Les caches à accès uniforme et non-uniforme== Intuitivement, le temps d'accès au cache est le même pour toutes les lignes de cache. Il s'agit de cache appelés '''caches à accès uniforme''', sous-entendu à temps d'accès uniforme. Mais sur les caches de grande capacité, il arrive souvent que le temps de propagation des signaux varie fortement suivant la ligne de cache à lire. D'ordinaire, on se cale sur la ligne de cache la plus lente pour caler la fréquence d'horloge du cache, même si on pourrait faire mieux. Cependant, les '''caches à accès non uniforme''' ont une latence différente pour chaque ligne d'un même cache. Certaines lignes de cache sont plus rapides que d'autres. Niveau terminologie, nous allons parler de caches UCA et NUCA : ''Uniform Access Cache'' pour les caches à accès uniforme, ''Non-Uniform Access Cache'' pour les caches à accès non-uniforme. [[File:Caches UCA et NUCA.png|vignette|Caches UCA et NUCA.]] Les caches NUCA et UCA sont souvent composés de plusieurs banques séparées, typiquement une par voie. Sur les caches UCA, les banques sont interconnectées avec le processeur de manière à ce que toutes les interconnexions ont la même longueur pour toutes les banques. Typiquement, les banques sont organisées en carré, avec les interconnexions qui partent du centre, avec une disposition en H, illustrée ci-contre Mais avec les caches NUCA, ce n'est pas le cas. Les interconnexions sont simplifiées et ont des longueurs différentes. Les caches NUCA n'ont pas tous le même genre d'interconnexions, qui dépendent du cache NUCA. En général, les interconnexion forme un réseau avec des sortes de routeurs qui redirigent les données/commandes vers la bonne destination : cache ou processeur. Les banques plus proches du processeur sont accessibles plus rapidement que celles éloignées, même si la différence n'est pas énorme. Les caches NUCA sont généralement associatifs par voie. Les plus simples utilisent une banque par voie pour le cache, ce qui fait que certaines voies répondent plus vite que les autres. La détection des succès de cache est alors plus rapide si la donnée lue/écrite est dans une voie/banque rapide. En théorie, les défauts de cache demandent de vérifier toutes les banques, et se calent donc sur la pire latence. Mais divers caches se débrouillent pour que ce ne soit pas le cas, soit en vérifiant les banquyes unes par une, soit par un mécanisme de recherche plus complexe. Les caches NUCA sont surtout utilisés pour les caches L3 et L4, éventuellement les caches L2. Les caches L1 sont systématiquement des caches UCA, car la latence de l'accès au cache L1 est utilisée par le processeur pour décider quand lancer les instructions. Pour simplifier, le processeur peut démarrer en avance une instruction avant qu'une opérande soit lue dans le cache L1, de manière à ce que la donnée arrive en entrée de l'ALU pile en même temps que l'instruction. Une histoire d'exécution dans le désordre et d'émission anticipée des instructions qu'on détaillera dans une bonne dizaine de chapitres. Toujours est-il que tout est plus simple pour le processeur si le cache L1 a un temps d'accès fixe. Par contre, les caches L3 et L4 sont traités en attendant que les données arrivent, le processeur reprend l'exécution des instructions quand les caches L3 et L4 ont terminé de répondre, pas avant. Avec l'association une banque = une voie, la correspondance ligne de cache → bloc de mémoire qui est statique : on ne peut pas déplacer le contenu d'une ligne de cache dans une autre portion de mémoire plus rapide suivant les besoins. Mais la recherche académique a étudié le cas où la correspondance entre une ligne de cache et une banque varie à l’exécution. Pour nommer cette distinction, on parle de caches S-NUCA (''Static NUCA'') et D-NUCA (''Dynamic NUCA''). Intuitivement, on s'attend à ce que les caches D-NUCA soient plus performants que les caches S-NUCA. Les lignes de cache les plus utilisées peuvent migrer dans une banque rapide, alors que les lignes de cache moins utilisées vont dans une banque éloignée. Les lignes de cache se répartissent dans le cache dynamiquement dans les banques où elles sont le plus adaptées. Mais paradoxalement, le gain des caches D-NUCA est presque nul, voire insignifiant. La raison est que les caches D-NUCA doivent incorporer un système pour déterminer dans quelle banque se situe la donnée pour détecter les succès/défauts de cache, ainsi qu'un système pour migrer les données entre banques. Et ce système augmente le temps d'accès au cache, réduisant à néant l'intérêt d'un cache D-NUCA. Si on économise quelques microsecondes de temps d'accès en passant d'un cache UCA à un cache S-NUCA, ce n'est pas pour les perdre en passant à un D-NUCA. La majorité des caches D-NUCA sont donc en cours de recherche, mais ne sont pas utilisés en pratique. ==La tolérance aux erreurs des caches== Une mémoire cache reste avant tout une mémoire RAM, bien que ce soit de la SRAM. Elle n'est pas parfaite et est donc sujette à des erreurs, qui peuvent inverser un bit ou l'effacer. De telles erreurs sont liées à des rayons cosmiques très énergétiques, à des particules alpha produites par le packaging ou le métal deu circuit intégré, peu importe : l'essentiel est qu'ils inversent parfois un bit. Les mémoires modernes savent se protéger contre de telles erreurs, en utilisant trois moyens. ===Les mémoires caches ECC et à bit de parité=== Le premier moyen est l'usage de codes correcteurs d'erreurs, qui ajoutent un ou plusieurs bits à la ligne de cache, dans les bits de contrôle. Les bits ajoutés dépendent de la donnée mémorisée dans le byte, et servent à détecter une erreur, éventuellement à la corriger. Le cas le plus simple ajoute un simple bit de parité pour chaque byte et se contente de détecter les erreurs dans les corriger. Les autres codes ECC permettent eux de corriger des erreurs, mais ils demandent d'ajouter au moins deux bits par byte, ce qui a un cout en circuit plus élevé. Un simple bit de parité permet de détecter qu'un bit a été inversé, mais ne permet pas de corriger l'erreur. En soi, ce n'est pas un problème. Si une erreur est détectée, on considère que la ligne de cache est invalide. Le cache gère la situation comme un défaut de cache et va chercher la donnée valide en mémoire RAM. Le cout en circuits est donc faible, mais les défauts de cache sont plus nombreux. Les codes ECC sont eux capables de corriger les erreurs, si elles ne modifient pas trop de bits d'un coup. Par contre, ils utilisent deux à trois bits par octet, ce qui a un cout en circuits loin d'être négligeable. Il y a donc un compromis entre défauts de cache et cout en circuits. La gestion de l'ECC est différente suivant le niveau de cache. Généralement, le cache L1 n'utilise pas l'ECC mais se contente d'un simple bit de parité pour éviter la corruption de ses données. Le cache étant petit, les corruptions de données sont assez rares, et les défauts de cache induits faibles. Il est plus important d'utiliser un code de détection d'erreur simple, rapide, qui ne ralentit pas le cache et n'augmente pas sa latence. Si une ligne de cache est corrompue, il a juste à aller lire la ligne depuis le cache L2, ou un niveau de cache inférieur. Du moins, c'est possible sur le cache en question est un cache inclusif et/ou ''write-through''. Par contre, le niveau de cache L2 et ceux en-dessous utilisent presque systématiquement une mémoire SRAM ECC. La raison principale étant que ce sont des caches assez gros, pour lesquels la probabilité d'une erreur est assez élevée. Plus une mémoire a de bits et prend de la place, plus il y a une chance élevée qu'un bit s'inverse. Et vu que les caches L2/L3/L4 sont par nature plus lents et plus gros, ils peuvent se permettre le cout en performance lié à l'ECC, idem pour le cout en circuit. Sans compter qu'en cas d'erreur, ils doivent aller lire la ligne de cache originelle en mémoire RAM, ce qui est très lent ! Mieux vaut corriger l'erreur sur place en utilisant l'ECC. ===L'usage du ''memory scrubbing'' sur les caches=== La plupart des erreurs ne changent qu'un seul bit dans un byte, mais le problème est que ces erreurs s'accumulent. Entre deux accès à une ligne de cache, il se peut que plusieurs erreurs se soient accumulées, ce qui dépasse les capacités de correction de l'ECC. Dans ce cas, il existe une solution appelée le ''memory scrubbing'', qui permet de résoudre le problème au prix d'un certain cout en performance. Pour rappel, l'idée est de vérifier les lignes de caches régulièrement, pour éviter que les erreurs s'accumulent. Par exemple, on peut vérifier chaque ligne de cache toutes les N millisecondes, et corriger une éventuelle erreur lors de cette vérification. En faisant des vérifications régulières, on garantir que les erreurs n'ont pas le temps de s'accumuler, sauf en cas de malchance avec des erreurs très proches dans le temps. Il ne s'agit pas d'un rafraichissement mémoire, car les SRAM ne s'effacent pas), mais ça a un effet similaire. Et évidemment, le ''memory scrubbing'' a un cout en performance. On peut faire une comparaison avec le rafraichissement mémoire : les rafraichissement réguliers réduisent les performances, car cela fait des accès en plus. Des accès qui sont de plus timés à des instants bien précis qui ne sont pas forcément les plus adéquats. Il est possible qu'un rafraichissement ait lieu en même temps qu'un accès mémoire et le rafraichissement a la priorité, ce qui réduit les performances. La même chose arrive avec les vérifications du ''memory scrubbing''. Malgré tout, la technique a été utilisée sur les caches de certains processeurs commerciaux, dont des processeurs AMD Athlon et Athlon 64. Elle est surtout utilisable sur les caches L2/L3, pour lesquels le cout du pseudo-rafraichissement est acceptable. ==Un exemple de cache : le cache d'instruction== La grande majorité des processeurs utilise deux caches L1 séparés : un '''cache d'instructions''' dédié aux instructions, et un autre pour les données. Une telle organisation permet de charger une instruction tout en lisant une donnée en même temps. Notons que seul le cache L1 est ainsi séparé entre cache de données et d'instructions. Le cache d’instruction se situe en théorie entre l'unité de chargement et l'unité de décodage. En effet, ce cache prend en entrée une adresse et fournit une instruction. L'adresse est fournie par le ''program counter'', l'instruction est envoyée dans l'unité de décodage. Le cache se situe donc entre les deux. Le cache de données L1 est connecté au chemin de données, et notamment aux unités de communication avec la mémoire, pas au séquenceur. [[File:Caches L1 et positions dans le processeur.png|centre|vignette|upright=2.5|Caches L1 et positions dans le processeur]] Les deux caches sont reliés au processeur par des bus séparés, l'ensemble ressemble à une architecture Harvard, mais où les caches remplacent les mémoires RAM/ROM. Le cache d'instruction prend la place de la mémoire ROM et le cache de données prend la place de la mémoire RAM. Évidemment, il y a des niveaux de caches en dessous des caches de données/instruction, et ceux-ci contiennent à la fois données et instructions, les deux ne sont pas séparées dans des mémoires/caches séparés. Raison pour laquelle l'ensemble est appelé une '''architecture Harvard modifiée'''. Architecture Harvard, car l'accès aux données et instructions se font par des voies séparées pour le processeur, modifiée car la séparation n'est effective que pour le cache L1 et pas les autres niveaux de cache, et encore moins la RAM. Sur les processeurs modernes, il arrive très souvent que le processeur doive charger une instruction et lire/écrire une donnée en même temps. Et à vrai dire, c'est la règle plus que l'exception. L'usage d'une architecture Harvard modifiée permet cela très facilement : on peut accéder au cache d'instruction via un bus, et au cache de donnée avec l'autre ===Pourquoi scinder le cache L1 en cache d'instruction et de données=== L'usage d'un cache d’instruction séparé du cache de données est à contraster avec l'usage d'un cache L1 multiport unique, capable de mémoriser à la fois instructions et données. Les deux solutions sont possibles ont été utilisées. Les premiers processeurs avaient un cache L1 unique et multiport, mais ce n'est plus le cas sur les processeurs modernes, car les contraintes ne sont pas les mêmes. Le compromis à faire est celui entre deux petits caches rapides et un gros cache plus lent. Pour rappel, plus un cache est petit, plus il est rapide et chauffe moins. Donc au lieu d'utiliser, par exemple, un gros cache lent de 64 Kibioctets, on utilise deux caches de 32 kibioctets, plus rapides. La capacité totale est la même, mais le temps d'accès plus faible. En termes de temps d'accès, la meilleure solution est celle des deux caches simple port. Mais pour ce qui est de l'économie de circuits, c'est moins évident. Entre deux mémoires simple port et une mémoire multiport, la différence en termes de transistors est ambigüe et dépend de la capacité des caches. La différence est surtout notable pour les gros caches, moins pour les petits caches. Il faut aussi tenir compte de la capacité effective. Avec deux caches séparés, la répartition de la capacité du cache L1 est fixée une bonne fois pour toutes. Par exemple, avec un cache d'instruction de 32 KB et un cache de données de 32 KB, impossible d'allouer 40 KB aux données et 20 aux instructions. Alors qu'avec un cache L1 unique de 64 KB, on pourrait le faire sans soucis. La répartition se fait naturellement, en fonction de la politique de remplacement du cache et est proche de l'optimal. C'est là un désavantage des caches d'instructions/données séparés : une capacité effective moindre. Tout cela explique pourquoi le cache L1 est le seul à être ainsi scindé en deux, avec une séparation entre instructions et données : les contraintes au niveau du cache L1 et L2 ne sont pas les mêmes. Pour les caches L1, le temps d'accès est plus important que la capacité, ce qui favorise les caches séparés. Par contre, pour les caches L2/L3/L4, le temps d'accès n'est pas déterminant, alors que la capacité effective et l'économie en circuits sont significatives. ===La connexion des caches L1 avec le cache L2=== Pour les connexions avec le cache L2, tout dépend du processeur. Certains utilisent un cache L2 multiport, qui permet aux deux caches L1 de lire ou écrire dans le cache L2 simultanément. [[File:Cache d'instructions.png|centre|vignette|upright=1.5|Cache d'instructions.]] Si le cache L2 ne gère pas les accès simultanés, il n'y a qu'un seul bus relié aux caches L1 et au cache L2. On doit effectuer un arbitrage pour décider quel cache a la priorité, chose qui est réalisé par un circuit d'arbitrage spécialisé. [[File:Circuit d'arbitrage du cache.png|centre|vignette|upright=1.5|Circuit d'arbitrage du cache.]] Généralement, les caches d'instructions peuvent se permettre d'être plus petits que les caches de données, car les programmes sont souvent plus petits que les données manipulées. Songez que des programmes de quelques mébioctets peuvent parfois remplir la RAM avec plusieurs gibioctets de données. Lancez votre navigateur internet et ouvrez une page web un peu chargée, pour vous en convaincre ! ===Les spécificités du cache d'instruction : lecture seule, bloquant, etc=== Les instructions sont rarement modifiées ou accédées en écritures, contrairement aux données. Et cela permet d'utiliser un cache simplifié pour les instructions. Autant un cache généraliste doit permettre les lectures et écritures depuis le processeur (avec les échanges avec la RAM), autant un cache d'instruction peut se contenter des lectures provenant du CPU et des échanges avec la RAM. Le cache d'instructions est donc très souvent en « lecture seule » : le processeur ne peut pas écrire dedans, mais juste le lire ou charger des instructions dedans. Un cache d'instruction est donc plus simple qu'un cache pour les données : on peut retirer les circuits en charge de l'écriture (mais on doit laisser un port d'écriture pour charger les instructions dedans). Le gain en circuits permet d'utiliser un cache d'instruction plus gros ou au contraire de laisser de la place pour le cache de données. Le gain en termes de capacité compense alors un peu les inconvénients des caches séparés. Par contre, cela complique la gestion du code automodifiant, c'est-à-dire des programmes dont certaines instructions vont aller en modifier d'autres, ce qui sert pour faire de l'optimisation ou est utilisé pour compresser ou cacher un programme (les virus informatiques utilisent beaucoup de genre de procédés). Quand le processeur exécute ce genre de code, il ne peut pas écrire dans ce cache L1 d'instructions, mais doit écrire dans le cache L2 ou en RAM, avant de recharger les instructions modifiées dans le cache L1. Cela qui prend du temps et peut parfois donner lieu à des erreurs si le cache L1 n'est pas mis à jour. Les algorithmes de remplacement des lignes de cache optimaux pour les données ne le sont pas pour les instructions, de même que la taille optimale du cache, la taille des lignes de cache optimale, ou même les algorithmes de préchargement. Par exemple, pour le remplacement des lignes de cache, un simple algorithme LRU est presque optimal pour les instructions, autant il peut donner de mauvaises performances quand on manipule beaucoup de tableaux. Cela justifie d'utiliser des caches spécialisés pour chacune. On peut adapter le cache d'instruction à son contenu, ce qui le rend plus rapide ou plus petit à performance égale. Les caches d'instructions sont généralement des caches bloquants. Il ne servirait à rien de rendre un cache d'instruction non-bloquant, le cout en circuits ne se traduirait pas par une augmentation significative des performances. À l'opposé, les caches de données sont non-bloquants sur les architectures modernes, pour des raisons de performance. Ce qui rend la séparation assez intéressante, les deux caches ayant des besoins différents et des implémentations différentes, cela permet d'optimiser le cout en transistors des caches. ===L'impact du cache d'instruction sur les performances=== Sur les architectures conventionnelles, le cache d'instruction a plus d'impact sur les performances que le cache de données. La raison principale est que les instructions ont une meilleure localité spatiale et temporelle que pour les données. Pour la localité spatiale, les instructions consécutives se suivent en mémoire, alors que rien ne garantit que des données utilisées ensemble soient regroupées en mémoire. Pour localité temporelle, elle est très variable pour les données, mais très courante pour les instructions du fait de l'usage fréquent des boucles et des fonctions. : La présence de branchements atténue la localité temporelle des instruction, sauf que la majorité des branchements sautent à un endroit très proche, seuls les appels de fonction brisent la localité spatiale. La conséquence est qu'il arrive que certains CPU aient un cache L1 d'instruction plus gros que celui pour les données. On parle alors de '''cache L1 asymétriques'''. Un exemple est celui des processeurs AMD de microarchitecture Zen, dont le cache d'instruction était deux fois plus gros que le cache de données. Leur cache d'instruction faisait 64 kibioctets, contre seulement 32 pour le cache de données. D'ailleurs, il existe des processeurs assez extrêmes qui se contentent d'un cache d'instruction unique, sans cache de données. C'est le cas sur les processeurs vectoriels ou les GPU que nous verrons dans les chapitres de fin de ce wikilivres. De tels processeurs sont spécialisés dans la manipulation de tableaux de données, traitement qui a une faible localité temporelle. En conséquence, utiliser un cache de données n'est pas vraiment utile, voire peu être contreproductif, alors qu'un cache d’instruction fonctionne parfaitement. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Compléments sur les mémoires de masse | prevText=Compléments sur les mémoires de masse | next=Le préchargement | nextText=Le préchargement }} </noinclude> lf7yi4ju0ccm3c9vw3buavlg2zjpyow Les cartes graphiques/Les cartes d'affichage 0 67388 765161 764896 2026-04-26T20:23:40Z Mewtow 31375 /* L'intérieur d'un GPU */ 765161 wikitext text/x-wiki Les cartes graphiques sont des cartes qui communiquent avec l'écran, pour y afficher des images. Les cartes graphiques modernes incorporent aussi des circuits de calcul pour accélérer du rendu 2D ou 3D. Dans ce chapitre, nous allons faire une introduction et expliquer ce qu'est une carte graphique et surtout : nous allons voir ce qu'il y a à l'intérieur, du moins dans les grandes lignes. ==Les cartes graphiques dédiées, intégrées et soudées== Vous avez sans doute déjà démonté votre PC pour en changer la carte graphique, vous savez sans doute à quoi elle ressemble. Sur les PC modernes, il s'agit d'un composant séparé, qu'on branche sur la carte mère, sur un port spécialisé. Du moins, c'est le cas si vous avez un PC fixe assez puissant. Mais il y a deux autres possibilités. <noinclude>[[File:PX7800 GTX (557505323).jpg|centre|vignette|Carte graphique dédiée PX 7800 GTX I]]</noinclude> La première est celle où la carte graphique est directement intégrée dans le processeur de la machine ! C'est quelque chose qui se fait depuis les années 2000-2010, avec l'amélioration de la technologie et la miniaturisation des transistors. Il est possible de mettre tellement de transistors sur une puce de silicium que les concepteurs de processeur en ont profité pour mettre une carte graphique peut puissante dans le processeur. Une autre possibilité, surtout utilisée sur les consoles de jeu et les PC portables, est celle où la carte graphique est composée de circuits soudés à la carte mère. Pour résumer, il faut distinguer trois types de cartes graphiques différentes : * Les '''cartes graphiques dédiées''', séparées dans une carte d'extension qu'on doit connecter à la carte mère via un connecteur dédié. * Les '''cartes graphiques intégrées''', qui font partie du processeur. * Les '''cartes graphiques soudées''' à la carte mère. Vous avez sans doute vu qu'il y a une grande différence de performance entre une carte graphique dédiée et une carte graphique intégrée. La raison est simplement que les cartes graphiques intégrées ont moins de transistors à leur disposition, ce qui fait qu'elles contiennent moins de circuits de calcul. Les cartes graphiques dédiées et soudées n'ont pas de différences de performances notables. Les cartes soudées des PC portables sont généralement moins performantes car il faut éviter que le PC chauffe trop, vu que la dissipation thermique est moins bonne avec un PC portable (moins de gros ventilos), ce qui demande d'utiliser une carte graphique moins puissante. Mais les cartes soudées des consoles de jeu n'ont pas ce problème : elles sont dans un boitier bien ventilés, on peut en utiliser une très puissante. ===Un PC avec plusieurs GPU : la commutation de GPU=== De nos jours, il y a de très fortes chances que votre ordinateur intègre plusieurs cartes graphique, peu importe que ce soit un PC portable ou fixe. Tous les PC ont une carte graphique intégrée, de faible performance, qui consomme peu d'énergie/électricité. Et si je dis presque tous, c'est parce que tous les processeurs commerciaux modernes incorporent une carte graphique intégrée. Le marché du processeur grand public est ainsi, seuls quelques processeurs dédiés aux serveurs n'ont pas de carte graphique intégrée. Et en plus de la carte intégrée, une bonne partie des PC intègrent aussi soit une carte dédiée, soit une carte soudée. Soudée sur les PC portables, dédiée sur les PC fixe. Dans le passé, il était possible de mettre plusieurs cartes graphiques dédiées dans un même PC, mais avec des conditions drastiques. ATI/AMD et NVIDIA avaient ajouté des fonctionnalités de multi-GPU, qui permettaient à deux GPU de travailler ensemble, afin de presque doubler les performances. Mais cela ne marchait qu'avec deux GPU NVIDIA ou deux GPU ATI/AMD, utiliser deux GPU de deux marques différentes ne marchait pas. Un chapitre entier sera dédié à ces techniques, mais nous n'en parlerons pas ici, car elles sont tombées en désuétude, aucun GPU grand public ne supporte ces technologies. S'il y a deux cartes graphiques, cela ne signifie pas que les deux sont utilisées en même temps. En effet, selon les circonstances, le PC va privilégier l'une ou l'autre. Dans les années 2010, le choix se faisait dans le BIOS : une des deux carte graphique était désactivée pour de bon, typiquement la carte intégrée. Les PC avec une carte dédiée désactivaient la carte intégrée dans le processeur, pour éviter tout conflit entre les deux cartes. De nos jours, les deux sont utilisables, mais pas en même temps. Le système d'exploitation, Windows ou linux, utilise soit la carte intégrée, soit la carte dédiée, suivant les besoins. La carte dédiée a de bonnes performance, mais elle consomme beaucoup d'énergie/électricité et chauffe plus. La carte graphique intégrée fait l'inverse : ses performances sont basses, mais elle consomme très peu et chauffe moins. La carte dédiée est donc utilisée quand on a besoin de performance, l'intégrée est utilisée quand elle suffit, afin de faire des économies. Prenons l'exemple d'un jeu vidéo : un jeu ancien et peu gourmand sera exécuté sur la carte intégrée, alors qu'un jeu récent/gourmand sera exécuté sur la carte dédiée. Le rendu du bureau de Windows/linux est réalisé par la carte graphique intégrée, pour économiser de l'énergie. ===La connexion des cartes graphiques à l'écran=== Prenons un PC fixe avec deux cartes graphiques, une intégrée et une dédiée. En général, il y a deux connecteurs pour l'écran, un qui est relié à la carte graphique intégrée, un autre qui est sur la carte dédiée proprement dite. Suivant là où vous brancherez l'écran, vous n'utiliserez pas la même carte graphique. Le système d'exploitation se charge d'envoyer les images à afficher à la carte graphique adéquate. Sur un PC portable ''gaming'', les choses sont différentes. Il n'y a qu'un seul connecteur pour l'écran, pas deux. Et dans ce cas, il y a deux possibilités. La première est la plus simple. Les deux cartes graphiques sont reliées au connecteur écran, par l'intermédiaire d'un circuit multiplexeur. Le circuit multiplexeur reçoit les images à afficher de la part des deux cartes graphiques et choisit l'une d'entre elle. C'est la solution la plus performante, car la carte dédiée peut afficher directement ses images à l'écran, sans avoir à les envoyer à la carte intégrée. Mais elle complexifie le câblage et demande d'ajouter un circuit multiplexeur, ce qui n'est pas gratuit. [[File:Commutation de GPU avec un MUX.png|centre|vignette|upright=2|Commutation de GPU avec un MUX]] Avec la seconde solution, une seule carte graphique est connectée à l'écran, généralement la carte intégrée. Si la carte dédiée est utilisée, les images qu'elle calcule sont envoyées à la carte intégrée pour ensuite être affichées à l'écran. On passe par un intermédiaire, mais le câblage est plus simple. [[File:Commutation de GPU sans MUX.png|centre|vignette|upright=2|Commutation de GPU sans MUX]] ==Les cartes d'affichage et leur architecture== Vous vous demandez comment est-ce possible qu'une carte graphique soit soudée ou intégrée dans un processeur. La raison est que les trois types de cartes graphiques sont très similaires, elles sont composées des mêmes types de composants, ce qu'il y a à l'intérieur est globalement le même, comme on va le voir dans ce qui suit. Au tout début de l'informatique, le rendu graphique était pris en charge par le processeur. Il calculait l'image à afficher et l'envoyait à l'écran, pixel par pixel. Le problème est que le processeur devait se synchroniser avec l'écran, pour envoyer les pixels au bon moment. Pour simplifier la vie des programmeurs, les fabricants de matériel ont inventé des cartes vidéo. Avec celles-ci, le processeur calcule l'image à envoyer à l'écran et la transmet à la carte d'affichage, sans avoir à se synchroniser avec l'écran. L'avantage est que le processeur n'a pas à se synchroniser avec l'écran, juste à envoyer l'image à une carte d'affichage. Les cartes d'affichage ne géraient pas le rendu 3D. Le processeur calculait une image, la copiait dans la mémoire vidéo, puis la carte d'affichage l'envoyait à l'écran au bon moment. Il n'y avait pas de circuits de calcul graphique, ni de circuits de décodage vidéo. Juste de quoi afficher une image à l'écran. Et mine de rien, il est intéressant d'étudier de telles cartes graphiques anciennes. De telles cartes graphiques sont ce que j'ai décidé d'appeler des '''cartes d'affichage'''. ===L'intérieur d'une carte d'affichage=== Une carte d'affichage contient plusieurs sous-circuits, chacun dédié à une fonction précise. * La '''mémoire vidéo''' est une mémoire RAM intégrée à la carte graphique, qui a des fonctions multiples. * L''''interface écran''', ou ''Display interface'', regroupe les connecteurs et tous les circuits permettant d'envoyer l'image à l'écran. * Le '''circuit d'interface avec le bus''' existe uniquement sur les cartes dédiées et éventuellement sur quelques cartes soudées. Il s'occupe des transmissions sur le bus PCI/AGP/PCI-Express, le connecteur qui relie la carte mère et la carte graphique. * Un circuit de contrôle qui commande le tout, appelé le '''''Video Display Controler'''''. [[File:Carte d'affichage - architecture.png|centre|vignette|upright=2|Carte d'affichage - architecture.]] La mémoire vidéo mémorise l'image à afficher, les deux circuits d'interfaçage permettent à la carte d'affichage de communiquer respectivement avec l'écran et le reste de l'ordinateur, le ''Video Display Controler'' commande les autres circuits. Le ''Video Display Controler'' sert de chef d'orchestre pour un orchestre dont les autres circuits seraient les musiciens. Le circuit de contrôle était appelé autrefois le CRTC, car il commandait des écrans dit CRT, mais ce n'est plus d'actualité de nos jours. La carte graphique communique via un bus, un vulgaire tas de fils qui connectent la carte graphique à la carte mère. Les premières cartes graphiques utilisaient un bus nommé ISA, qui fût rapidement remplacé par le bus PCI, plus rapide, lui-même remplacé par le bus AGP, puis le bus PCI-Express. Ce bus est géré par un contrôleur de bus, un circuit qui se charge d'envoyer ou de réceptionner les données sur le bus. Les circuits de communication avec le bus permettent à l'ordinateur de communiquer avec la carte graphique, via le bus PCI-Express, AGP, PCI ou autre. Il contient quelques registres dans lesquels le processeur pourra écrire ou lire, afin de lui envoyer des ordres du style : j'envoie une donnée, transmission terminée, je ne suis pas prêt à recevoir les données que tu veux m'envoyer, etc. Il y a peu à dire sur ce circuit, aussi nous allons nous concentrer sur les autres circuits. Le circuit d'interfaçage écran est au minimum un circuit d’interfaçage électrique se contente de convertir les signaux de la carte graphique en signaux que l'on peut envoyer à l'écran. Il s'occupe notamment de convertir les tensions et courants : si l'écran demande des signaux de 5 Volts mais que la carte graphique fonctionne avec du 3,3 Volt, il y a une conversion à faire. De même, le circuit d'interfaçage électrique peut s'occuper de la conversion des signaux numériques vers de l'analogique. L'écran peut avoir une entrée analogique, surtout s'il est assez ancien. Les anciens écrans CRT ne comprenaient que des données analogiques et pas le binaire, alors que c'est l'inverse pour la carte graphique, ce qui fait que le circuit d'interfaçage devait faire la conversion. La conversion était réalisée par un circuit qui traduit des données numériques (ici, du binaire) en données analogiques : le '''convertisseur numérique-analogique''' ou DAC (''Digital-to-Analogue Converter''). Au tout début, le circuit d’interfaçage était un DAC combiné avec des circuits annexes, ce qu'on appelle un RAMDAC (''Random Access Memory Digital-to-Analog Converter''). De nos jours, les écrans comprennent le binaire sous réserve qu'il soit codé suivant le standard adapté et les cartes graphiques n'ont plus besoin de RAMDAC. Il y a peu à dire sur les circuits d'interfaçage. Leur conception et leur fonctionnement dépendent beaucoup du standard utilisé. Sans compter qu'expliquer leur fonctionnement demande de faire de l'électronique pure et dure, ce qui est rarement agréable pour le commun des mortels. Par contre, étudier le circuit de contrôle et la mémoire vidéo est beaucoup plus intéressant. Plusieurs chapitres seront dédiés à leur fonctionnement. Mais parlons maintenant des GPU modernes et passons à la section suivante. ===Un historique rapide des cartes d’affichage=== Les cartes d'affichages sont opposées aux cartes accélératrices 2D et 3D, qui permettent de décharger le processeur d'une partie du rendu 2D/3D. Pour cela, elles intègrent des circuits spécialisés. Vous imaginez peut-être que les cartes d'affichage sont apparues en premier, puis qu'elles ont gagné en puissance et en fonctionnalités pour devenir d'abord des cartes accélératrices 2D, puis des cartes 3D. C'est une suite assez logique, intuitive. Et ce n'est pas du tout ce qui s'est passé ! Les cartes d'affichage pures, sans rendu 2D, sont une invention des premiers PC. Elles sont arrivées alors que les consoles de jeu avaient déjà des cartes hybrides entre carte d'affichage et cartes de rendu 2D depuis une bonne décennie. Sur les consoles de jeu ou les microordinateurs anciens, il n'y avait pas de cartes d'affichage séparée. À la place, le système vidéo d'un ordinateur était un ensemble de circuits soudés sur la carte mère. Les consoles de jeu, ainsi que les premiers micro-ordinateurs, avaient une configuration fixée une fois pour toute et n'étaient pas upgradables. Mais avec l'arrivée de l'IBM PC, les cartes d’affichages se sont séparées de la carte mère. Leurs composants étaient soudés sur une carte qu'on pouvait clipser et détacher de la carte mère si besoin. Et c'est ainsi que l'on peut actuellement changer la carte graphique d'un PC, alors que ce n'est pas le cas sur une console de jeu. La différence entre les deux se limite cependant à cela. Les composant d'une carte d'affichage ou d'une console de jeu sont globalement les mêmes. Aussi, dans ce qui suit, nous parlerons de carte d'affichage pour désigner cet ensemble de circuits, peu importe qu'il soit soudé à la carte mère ou placé sur une carte d’affichage séparée. C'est un abus de langage qu'on ne retrouvera que dans ce cours. ===Les différents types de cartes d'affichage=== Dans la suite du cours, nous allons voir que toutes les cartes d'affichage ne fonctionnent pas de la même manière. Et ces différences font qu'on peut les classer en plusieurs types distincts. Leur classement s'explique par un fait assez simple : une image prend beaucoup de mémoire ! Par exemple, prenons le cas d'une image en niveaux de gris d'une résolution de 320 par 240 pixels, chaque pixel étant codé sur un octet. L'image prend alors 76800 octets, soit environ 76 kiloctets. Mine de rien, cela fait beaucoup de mémoire ! Et si on ajoute le support de la couleur, cela triple, voire quadruple la taille de l'image. Les cartes graphiques récentes ont assez de mémoire pour stocker l'image à afficher. Une partie de la mémoire vidéo est utilisée pour mémoriser l'image à afficher. La portion en question s'appelle le ''framebuffer'', '''tampon d'image''' en français. Il s'agit là d'une solution très simple, mais qui demande une mémoire vidéo de grande taille. Les systèmes récents peuvent se le permettre, mais les tout premiers ordinateurs n'avaient pas assez de mémoire vidéo. Les cartes d'affichages devaient se débrouiller avec peu de mémoire, impossible de mémoriser l'image à afficher entièrement. Pour compenser cela, les cartes d'affichage anciennes utilisaient diverses optimisations assez intéressantes. La première d'entre elle utilise pour cela le fonctionnement des anciens écrans CRT, qui affichaient l'image ligne par ligne. Pour rappel, l'image a afficher à l'écran a une certaine résolution : 320 pixels pour 240 pixels, par exemple. Pour l'écran CRT, l'image est composée de plusieurs lignes. Par exemple, pour une résolution de 640 par 480, l'image est découpée en 480 lignes, chacune faisant 640 pixels de long. L'écran est conçu pour qu'on lui envoie les lignes les unes après les autres, avec une petite pause entre l'affichage/envoi de deux lignes. Précisons que les écrans LCD ont abandonné ce mode de fonctionnement. L'idée est alors la suivante : la mémoire vidéo ne mémorise que la ligne en cours d'affichage par l'écran. Le processeur met à jour la mémoire vidéo entre l'affichage de deux lignes. La mémoire vidéo n'est alors pas un tampon d'image, mais un '''tampon de ligne'''. Le défaut de cette technique est qu'elle demande que le processeur et la carte d'affichage soient synchronisés, de manière à ce que les lignes soient mises à jour au bon moment. L'avantage est que la quantité de mémoire vidéo nécessaire est divisée par un facteur 100, voire plus, égal à la résolution verticale (le nombre de lignes). Une autre méthode, appelée le '''rendu en tiles''', est formellement une technique de compression d'image particulière. L'image à afficher est stockée sous un format compressé en mémoire vidéo, mais est décompressée pixel par pixel lors de l'affichage. Il nous est difficile de décrire cette technique maintenant, mais un chapitre entier sera dédié à cette technique. Le chapitre en question abordera une technique similaire, appelée le rendu en mode texte, qui servira d'introduction propédeutique. Le rendu en tiles et l'usage d'un tampon ligne sont deux optimisations complémentaires. Il est ainsi possible d'utiliser soit l'une, soit l'autre, soit les deux. En clair, cela donne quatre types de cartes d'affichage distincts : * les cartes d'affichage à tampon d'image ; * les cartes à tampon d'images en rendu à tiles ; * les cartes d'affichage à tampon de ligne ; * les cartes d'affichage à tampon de ligne en rendu à tiles. {|class="wikitable" |- ! ! Rendu normal ! Rendu à tile |- ! Tampon d'image | Cartes graphiques post-années 90, standard VGA sur PC | Consoles de jeu 8 bits et 16 bits, standards CGA, MGA |- ! Tampon de ligne | Consoles de jeu ATARI 2600 et postérieures | Console de jeu néo-géo |} Lez prochains chapitres porteront surtout sur les cartes d'affichages à tampon d'image, plus simples à, expliquer. Deux chapitres seront dédiés respectivement aux cartes à rendu en tile, et aux cartes à tampon de ligne. ==Les cartes graphiques actuelles sont très complexes== Les cartes graphiques actuelles sont des cartes d'affichage améliorées auxquelles on a ajouté des circuits annexes, afin de leur donner des capacités de calcul pour le rendu 2D et/ou 3D, mais elles n'en restent pas moins des cartes d'affichages. La seule différence est que le processeur n’envoie pas une image à la mémoire vidéo, mais que l'image à afficher est calculée par la carte graphique 2D/3D. Les calculs autrefois effectués sur le CPU sont donc déportés sur la carte graphique. Si vous analysez une carte graphique récente, vous verrez que les circuits des cartes d'affichage sont toujours là, bien que noyés dans des circuits de rendu 2D/3D. On retrouve une mémoire vidéo, une interface écran, un circuit d'interface avec le bus, un ''Video Display Controler''. L'interface écran est par contre fusionnée avec le ''Video Display Controler''. Mais à ces circuits d'affichage, sont ajoutés un '''GPU''' (''Graphic Processing Unit''), qui s'occupe du rendu 3D, du rendu 2D, mais aussi d'autres fonctions comme du décodage de fichiers vidéos. [[File:Architecture d'une carte graphique avec un GPU.png|centre|vignette|upright=2|Architecture d'une carte graphique avec un GPU]] ===L'intérieur d'un GPU=== Le GPU est un composant assez complexe, surtout sur les cartes graphiques modernes. Il regroupe plusieurs circuits aux fonctions distinctes. * Un '''''Video Display controler''''' est toujours présent, mais ne sera pas représenté dans ce qui suit. * Des '''circuit de rendu 2D''' séparés peuvent être présents, mais sont la plupart du temps intégrés au VDC. * Les '''circuits de rendu 3D''' s'occupent du rendu 3D. * Les '''circuits de décodage vidéo''', pour améliorer la performance du visionnage de vidéos. Les trois circuits précédents sont gouvernés par un '''processeur de commande''', un circuit assez difficile à décrire. Pour le moment, disons qu'il s'occupe de répartir le travail entre les différents circuits précédents. Le processeur de commande reçoit du travail à faire, envoyé par le CPU via le bus, et le redistribue dans les trois circuits précédents. Par exemple, si on lui envoie une vidéo encodée en H264, il envoie le tout au circuit vidéo. De plus, il le configure pour qu'il la décode correctement : il dit au circuit que c'est une vidéo encodée en H264, afin que le circuit sache comment la décoder. Ce genre de configuration est aussi présente sur les circuits de rendu 2D et 3D, bien qu'elle soit plus compliquée. [[File:Microarchitecture simplifiée d'un GPU.png|centre|vignette|upright=2|Microarchitecture simplifiée d'un GPU]] Un GPU contient aussi un contrôleur mémoire, qui est une interface entre le GPU et la mémoire vidéo. Interface électrique, car GPU et mémoire n'utilisent pas les mêmes tensions et qu'en plus, leur interconnexion demande de gérer pas mal de détails purement électriques. Mais aussi interface de communication, car communiquer entre mémoire vidéo et GPU demande de faire pas mal de manipulations, comme gérer l'adressage et bien d'autres choses qu'on ne peut pas encore détailler ici. Un GPU contient aussi d'autres circuits annexes, comme des circuits pour gérer la consommation électrique, un BIOS vidéo, etc. L'interface écran et l'interface avec le bus sont parfois intégrées au GPU. ===Un historique simplifié des pipelines 2D/3D/vidéo=== Avant les années 90, les cartes graphiques des ordinateurs personnels et des consoles de jeux se résumaient à des circuits d'accélération 2D, elles n'avaient pas de décodeurs vidéos ou de circuits de rendu 3D. C'est dans les années 90 qu'elles ont commencé à incorporer des circuits d'accélération 3D. Puis, après les années 2000-2010, elles ont commencé à intégrer des circuits de décodage vidéo, aux alentours des années 2010. En parallèle, les circuits de rendu 2D ont progressivement été réduits puis abandonnés. Les circuits de rendu 2D sont des circuits qui ne sont pas programmables, mais qu'on peut configurer. On dit aussi que ce sont des '''circuits fixes'''. Ils sont opposés aux circuits programmables, basés sur des processeurs. Les circuits de rendu 3D étaient initialement des circuits fixes eux aussi, mais sont devenus de plus en plus programmables avec le temps. Les GPU d'après les années 2000 utilisent un mélange de circuits programmables et fixes assez variable. Nous reviendrons là-dessus dans un chapitre dédié. [[File:Microarchitecture globale d'un GPU.png|centre|vignette|upright=2|Microarchitecture globale d'un GPU]] Pour les circuits de décodage vidéo, les choses sont assez complexes. Initialement, ils s'agissait de circuits fixes, qu'on pouvait programmer. Un GPU de ce type est illustré ci-dessus. Mais de nos jours, les calculs de décodage vidéo sont réalisés sur les processeurs de shaders. Les circuits de rendu 3D et de décodage vidéo ont chacun des circuits fixes dédiés, mais partagent des processeurs. [[File:GPU avec processeurs de shaders partagés entre rendu 3D et décodage vidéo.png|centre|vignette|upright=2|GPU avec processeurs de shaders partagés entre rendu 3D et décodage vidéo]] Les cinq prochains chapitres vont parler des cartes d'affichage et des cartes accélératrices 2D, les deux étant fortement liées. C'est seulement dans les chapitres qui suivront que nous parlerons des cartes 3D. Les cartes 3D sont composées d'une carte d'affichage à laquelle on a rajouté des circuits de calcul, ce qui fait qu'il est préférable de faire ainsi : on voit ce qui est commun entre les deux d'abord, avant de voir le rendu 3D ensuite. De plus, le rendu 3D est plus complexe que l'affichage d'une image à l'écran, ce qui fait qu'il vaut mieux voir cela après. <noinclude> {{NavChapitre | book=Les cartes graphiques | next=Le Video Display Controler | nextText=Le Video Display Controler }}{{autocat}} </noinclude> 2p2g1lpmvufsp0rkyb5jrdwwhu44bww Les cartes graphiques/L'évolution vers la programmabilité : les GPUs 0 67392 765151 764881 2026-04-26T20:14:43Z Mewtow 31375 /* Les précurseurs grand public : les bornes d'arcade */ 765151 wikitext text/x-wiki Il est intéressant d'étudier le hardware des cartes graphiques en faisant un petit résumé de leur évolution dans le temps. En effet, leur hardware a fortement évolué dans le temps. Et il serait difficile à comprendre le hardware actuel sans parler du hardware d'antan. En effet, une carte graphique moderne est partiellement programmable. Certains circuits sont totalement programmables, d'autres non. Et pour comprendre pourquoi, il faut étudier comment ces circuits ont évolués. Le hardware des cartes graphiques a fortement évolué dans le temps, ce qui n'est pas une surprise. Les évolutions de la technologie, avec la miniaturisation des transistors et l'augmentation de leurs performances a permis aux cartes graphiques d'incorporer de plus en plus de circuits avec les années. Avant l'invention des cartes graphiques, toutes les étapes du pipeline graphique étaient réalisées par le processeur : il calculait l'image à afficher, et l’envoyait à une carte d'affichage 2D. Au fil du temps, de nombreux circuits furent ajoutés, afin de déporter un maximum de calculs vers la carte vidéo. Le rendu 3D moderne est basé sur le placage de texture inverse, avec des coordonnées de texture, une correction de perspective, etc. Mais les anciennes consoles et bornes d'arcade utilisaient le placage de texture direct. Et cela a impacté le hardware des consoles/PCs de l'époque. Avec le placage de texture direct, il était primordial de calculer la géométrie, mais la rasterisation était le fait de VDC améliorés. Aussi, les premières bornes d'arcade 3D et les consoles de 5ème génération disposaient processeurs pour calculer la géométrie et de circuits d'application de textures très particuliers. A l'inverse, les PC utilisaient un rendu inverse, totalement différent. Sur les PC, les premières cartes graphiques avaient un circuit de rastérisation et des unités de textures, mais pas de circuits géométriques. ==Les premières cartes graphiques, pour ''mainframes'' et stations de travail== Dès les années 70-80, le rendu 3D était utilisé par de nombreuses entreprises industrielles : des applications de visualisation 3D étaient utilisées en architecture, des applications de conception assistée par ordinateur étaient déjà d'utilisation courante, sans compter les simulateurs de vol utilisés par l'armée et les instructeurs qui formaient les pilotes d'avion. Le rendu 3D était aussi étudié au niveau académique, la recherche en 3D était déjà florissante. Il existait même du matériel spécifiquement conçu pour le rendu graphique, mais celui-ci était spécifiquement dédié à des super-calculateurs ou des ''workstations'' (des sortes d'ancêtres des PC, très puissants pour l'époque, mais conçus uniquement pour les entreprises). ===Le début des années 80 : le rendu en fils de fer=== Le tout premier système de ce genre était le '''''Line Drawing System-1''''' de l'entreprise Evans & Sutherland, daté de 1969. Ce n'est ni plus ni moins que le toute premier circuit graphique séparé du processeur ayant existé. C'est en un sens la toute première carte graphique, le tout premier GPU. Il prenait la forme d'un périphérique qui se connectait à l'ordinateur d'un côté et était relié à l'écran de l'autre. Il était compatible avec un grand nombre d'ordinateurs et de processeurs existants. Il a été suivi par plusieurs successeurs, nommés ''Picture System 1, 2'' et le ''PS300 series''. [[File:Evans & Sutherland LDS-1 (1).jpg|vignette|Evans & Sutherland LDS-1 (1)]] Ils permettaient de faire du rendu en fil de fer, sans texture ni même sans polygones colorés. Un tel rendu était utile pour des applications assez limitées : architecture, dessin de molécules pour les entreprises pharmaceutique et certains centres de recherche, l'aérospatiale, etc. Ces cartes graphiques étaient utilisées de concert avec des écrans appelés '''écrans vectoriels''' (''vector display''). Pour simplifier, ils ressemblaient à des écrans CRT, sauf que le faisceau d'électron ne balayait pas l'écran ligne par ligne, mais traçait des lignes arbitraires à l'écran. On lui précisait deux points de coordonnées x1,y1 ; et x2,y2 ; puis l'écran tracait une ligne entre ces deux points. En général, la ligne tracée était maintenue pendant un long moment, entre plusieurs secondes et plusieurs minutes. L'intérieur du circuit était assez simple : un circuit de multiplication de matrice pour les calculs géométriques, un rastériser simplifié (le ''clipping diviser''), un circuit de tracé de lignes, et un processeur de contrôle pour commander les autres circuits. Le fait que ces trois circuits soient séparés permettait une implémentation en pipeline, où plusieurs portions de l'image pouvaient être calculées en même temps : pendant que l'une est dans l'unité géométrique, l'autre est dans le rastériseur et une troisième est en cours de tracé. [[File:Lds1blockdiagram05.svg|centre|vignette|upright=2|Architecture du LDS-1. Le processeur de contrôle n'est pas représenté.]] Le processeur de contrôle exécute un programme qui se charge de commander l'unité géométrique et les autres circuits. Le programme en question est fourni par le programmeur, le LDS-1 est donc totalement programmable. Il lit directement les données nécessaires pour le rendu dans la mémoire de l’ordinateur et le programme exécuté est lui aussi en mémoire principale. Il n'a pas de mémoire vidéo dédiée, il utilise la RAM de l'ordinateur principal. Le multiplieur de matrices est plus complexe qu'on pourrait s'y attendre. Il ne s'agit pas que d'un circuit arithmétique tout simple, mais d'un véritable processeur avec des registres et des instructions machine complexes. Il contient plusieurs registres, l'ensemble mémorisant 4 matrices de 16 nombres chacune (4 lignes de 4 colonnes). Un nombre est codé sur 18 bits. Les registres sont reliés à un ensemble de circuits arithmétiques, des additionneurs et des multiplieurs. Le circuit supporte des instructions de copie entre registres, pour copier une ligne d'une matrice à une autre, des instructions LOAD/STORE pour lire ou écrire dans la mémoire RAM, etc. Il supporte aussi des multiplications en 2D et 3D. Le ''clipping divider'' est un circuit assez complexe, contenant un processeur à accumulateur, une mémoire ROM pour le programme du processeur. Le programme exécuté par le processeur est un petit programme de 62 instructions, stocké dans la ROM. L'algorithme du ''clipping divider'' est décrite dans le papier de recherche "A clipping divider", écrit par Robert Sproull. Un détail assez intéressant est que le résultat en sortie de l'unité géométrique et du rastériseur peuvent être envoyés à l'ordinateur en parallèle du rendu. C'était très utile sur les anciens ordinateurs qui étaient connectés à plusieurs terminaux. Le LDS-1 calculait la géométrie et le rendu, et le tout pouvait petre envoyé à d'autres composants, comme des terminaux, une imprimante, etc. ===Les systèmes ultérieurs : rendu à triangles colorés et texturé=== Les systèmes précédents étaient très limités : ils calculaient la géométrie et n'avaient pas de ''framebuffer'', ni de tampon de profondeur, ni gestion de l'éclairage, ni quoique ce soit. De tels systèmes étaient donc des accélérateurs géométriques que de vrais systèmes graphiques complets, du fait de l'absence de ''framebuffer''. Ils étaient composés de processeurs spécialisés dans les calculs à virgule flottante, faisant des calculs géométriques, et éventuellement d'un processeur pour la rastérisation. La raison est que la RAM était très chère et que créer des circuits fixes étaient très chers et peu disponibles. Par contre, les processeurs à virgule flottante étaient peu chers et facile à trouver. Vers la fin des années 80, grâce à la baisse du prix de la RAM et la démocratisation des ASIC (des circuits fixes fait sur mesure), ajouter un ''framebuffer'' est est devenu possible. C'est alors que sont apparus les '''systèmes de rendu 3D de première génération'''. De tels systèmes ont permis d'implémenter le rendu à primitives colorées qu'on a vu il y a quelques chapitres, à savoir un rendu où les triangles sont coloriés avec une couleur unique. Les systèmes de première génération étaient simples : des processeurs pour le calcul de la géométrie, un circuit de rastérisation, une RAM pour le ''framebuffer'' et des ASIC servant de ROPs très simples. Il n'y avait pas d'élimination des pixels cachés, pas de textures, et encore moins d'éclairage par pixels. Le premier système de ce genre était le ''Shaded Picture System'', toujours par Evans & Sutherland. Il ne gérait pas la couleur et ne pouvait afficher que des images en noir et blanc, mais il gérait l'éclairage par sommet (''vertex lighting''). Il a rapidement été dépassé par les systèmes de l'entreprise ''Silicon Graphics Inc'' (SGI), ainsi que ceux de l'entreprise Apollo avec sa série Apollo DN. Les '''systèmes de seconde génération''' sont apparus vers la fin des années 80, et se distinguent des précédents par l'ajout un tampon de profondeur. Ils intègrent aussi des capacités d'éclairage par pixel, à savoir de l'éclairage plat, de Gouraud, voire de Phong ! Enfin, les '''systèmes de troisième génération''' ont acquis des capacités de placage de texture, que les systèmes précédents n'avaient pas. Ils ont aussi ajouté un support de l'antialiasing. Les systèmes SGI avec placage de texture ont déjà été abordé au chapitre précédent, dans la section sur les GPU en mode immédiat et à ''tile''. Aussi, nous ne reviendrons pas dessus. [[File:Evolution de l'architecture des premières cartes graphiques, dans les années 80-90.png|centre|vignette|upright=2.5|Evolution de l'architecture des premières cartes graphiques, dans les années 80-90]] Les systèmes de première, seconde et troisième génération avaient de nombreux points communs. En premier lieu, ils étaient fabriqués en connectant plusieurs cartes électroniques : une carte pour les calculs géométriques, une ou plusieurs cartes pour le reste du rendu graphique, une carte dédiée au VDC et avec un connecteur écran. Les transistors de l'époque n'étaient pas encore miniaturisés, ce qui fait que le système graphique ne pouvait pas tenir sur une seule carte électronique. Il n'y avait donc pas de carte graphique proprement dit, mais un équivalent éclaté sur plusieurs cartes électroniques. La carte pour la géométrie contenait typiquement une mémoire FIFO pour accumuler les commandes de rendu, un processeur de commande, et plusieurs processeurs géométriques. Les processeurs géométriques étaient parfois conçus sur mesure, comme l'a été le le ''Geometry Engine'' de SGI. Mais il est arrivé qu'ils utilisent des processeurs commerciaux comme le Weitek 3222, l'Intel i860, etc. Les processeurs pouvaient être placés en série ou en parallèle, comme expliqué dans le chapitre précédent. Le circuit de rastérisation était réalisé soit avec un processeur dédié, soit avec un circuit fixe, soit un mélange des deux. La rastérisation est en effet réalisée en plusieurs étapes, certaines peuvent être implémentées avec un processeur et d'autres avec des circuits fixes. Un point important est qu'à l'époque, le rendu n'utilisait pas que des triangles, mais des polygones en général. Ce n'est que par la suite que le rendu s'est focalisé sur les triangles et les ''quads'' (quadrilatères). Il arrivait que le système graphique gérait partiellement des polygones concaves, voire convexes. Sur les systèmes SGI, les calculs géométriques se faisaient avec des polygones, que la rastérisation découpait en triangles, le reste du rendu se faisait avec des triangles. Les stations de travail Apollo DN 10000VS découpaient les polygones en trapézoïdes orientés à l'horizontale, alignés avec des ''scanlines''. D'autres systèmes découpaient tout en triangle lors de l'étape géométrique ==Les précurseurs grand public : les bornes d'arcade== L'accélération du rendu 3D sur les bornes d'arcade était déjà bien avancé dès les années 90. Les bornes d'arcade ont toujours été un segment haut de gamme de l'industrie du jeu vidéo, aussi ce n'est pas étonnant. Le prix d'une borne d'arcade dépassait facilement les 10 000 dollars pour les plus chères et une bonne partie du prix était celui du matériel informatique. Le matériel était donc très puissant et débordait de mémoire RAM comparé aux consoles de jeu et aux PC. [[File:Sega ST-V Dynamite Deka PCB 20100324.jpg|vignette|Sega ST-V Dynamite Deka PCB 20100324]] La plupart des bornes d'arcade utilisaient du matériel standardisé entre plusieurs bornes. A l'intérieur d'une borne d'arcade se trouve une '''carte de borne d'arcade''' qui est une carte mère avec un ou plusieurs processeurs, de la RAM, une carte graphique, un VDC et pas mal d'autres matériels. La carte est reliée aux périphériques de la borne : joysticks, écran, pédales, le dispositif pour insérer les pièces afin de payer, le système sonore, etc. Le jeu utilisé pour la borne est placé dans une cartouche qui est insérée dans un connecteur spécialisé. Les cartes de bornes d'arcade étaient généralement assez complexes, elles avaient une grande taille et avaient plus de composants que les cartes mères de PC. Chaque carte contenait un grand nombre de chips pour la mémoire RAM et ROM, et il n'était pas rare d'avoir plusieurs processeurs sur une même carte. Et il n'était pas rare d'avoir trois à quatre cartes superposées dans une seule borne. Pour ceux qui veulent en savoir plus, Fabien Sanglard a publié gratuitement un livre sur le fonctionnement des cartes d'arcade CPS System, disponible via ce lien : [https://fabiensanglard.net/b/cpsb.pdf The book of CP System]. Les premières cartes graphiques des bornes d'arcade étaient des cartes graphiques 2D auxquelles on avait ajouté quelques fonctionnalités. Les sprites pouvaient être tournés, agrandit/réduits, ou déformés pour simuler de la perspective et faire de la fausse 3D. Par la suite, le vrai rendu 3D est apparu sur les bornes d'arcade. Dès 1988, la carte d'arcade Namco System 21 et Sega Model 1 géraient les calculs géométriques. Quelques années plus tard, les cartes graphiques se sont mises à supporter un éclairage de Gouraud et du placage de texture. Par exemple, le Namco System 22 et la Sega model 2 supportaient des textures 2D et comme le filtrage de texture (bilinéaire et trilinéaire), le mip-mapping, et quelques autres. Au passage, les cartes graphiques de la Namco System 22 étaient développées en partenariat avec Eans & Sutherland, qui avait commencé à se diversifier dans le marché grand public. Les cartes graphiques de l'époque faisaient les calculs géométriques sur plusieurs processeurs, généralement des processeurs de type DSP (des processeurs spécialisés dans le traitement de signal). Par exemple, la Namco System 2 utilisait 4 DSP de marque Texas Instruments TMS320C25, cadencés à 24,576 MHz. La carte d'arcade Sega Model 1 utilisait quant à elle un DSP spécialisé dans les calculs géométriques. Par la suite, les bornes d'arcade ont réutilisé le hardware des PC et autres consoles de jeux. ==La 3D sur les consoles de quatrième/cinquième génération== Les consoles avant la quatrième génération de console étaient des consoles purement 2D, sans circuits d'accélération 3D. Leur carte graphique était un simple VDC 2D, plus ou moins performant selon la console. Les premières consoles de jeu capables de rendu 3D par elles-mêmes sont les consoles dites de 5ème génération. Il y a diverses manières de classer les consoles en générations, la plus commune place la 3D à la 5ème génération, mais détailler ces controverses quant à ce classement nous amènerait trop loin. Les consoles de génération avaient une architecture assez différente des systèmes antérieurs. Les systèmes SGI et assimilés pouvaient se permettre de couter assez cher, d'utiliser beaucoup de circuits, de prendre beaucoup de place. Les bornes d'arcade sont aussi dans ce cas. Aussi, il n'était pas rare que les cartes 3D de l'époque tiennent sur plusieurs cartes électroniques séparées. Mais une console ne peut pas se permettre ce genre de folies. Aussi, les cartes 3D des consoles de l'époque tenaient dans un seul circuit intégré, comme il est d'usage de nos jours. La conséquence est que certains circuits étaient fortement simplifiés, sur les consoles de cinquième génération. Et cela a impacté l'architecture interne des GPU des consoles. Les systèmes SGI avaient plusieurs processeurs pour calculer la géométrie, couplés à plusieurs unités non-programmables pour les pixels/textures. Les cartes 3D des consoles gardaient cette organisation : processeurs pour la géométrie, circuits fixes pour le reste. Mais elles se débrouillaient souvent avec un seul processeur, voire aucun ! Dans ce dernier cas, la géométrie était calculée sur le processeur principal, le CPU. Les unités pour les pixels étaient aussi moins nombreuses, mais il y en avait plusieurs, pour profiter de l'amplification des pixels. : Les cartes 3D des consoles de jeu utilisaient le placage de texture inverse, avec quelques exceptions qui utilisaient le placage de texture direct. ===Le rendu 3D sur les consoles de quatrième génération : la SNES=== Plus haut, j'ai dit que les consoles de quatrième génération n'avaient pas de carte accélératrice 3D. Pourtant, elles ont connus quelques jeux en vraie 3D. La raison à cela est que la 3D était calculée par un GPU placé dans les cartouches du jeu ! Par exemple, les cartouches de Starfox et de Super Mario 2 contenaient un coprocesseur Super FX, qui gérait des calculs de rendu 2D/3D. En tout, il y a environ 16 coprocesseurs pour la SNES et on en trouve facilement la liste sur le net. La console était conçue pour, des pins sur les ports cartouches étaient prévues pour des fonctionnalités de cartouche annexes, dont ces coprocesseurs. Ces pins connectaient le coprocesseur au bus des entrées-sorties. Les coprocesseurs des cartouches de NES avaient souvent de la mémoire rien que pour eux, qui était intégrée dans la cartouche. Ceci étant dit, passons aux consoles de cinquième génération. ===La Nintendo 64 : un GPU avancé=== La Nintendo 64 avait le GPU le plus complexe comparé aux autres consoles, et dépassait même les cartes graphiques des PC. Il faut dire que son GPU a été conçu avec l'aide de l'entreprise SGI, dont on a vu les systèmes graphiques plus haut. Le GPU de la N64 incorporait une unité pour les calculs géométriques, un circuit de rasterisation, une unité de textures et un ROP final pour les calculs de transparence/brouillard/antialiasing, ainsi qu'un circuit pour gérer la profondeur des pixels. En somme, tout le pipeline graphique était implémenté dans le GPU de la Nintendo 64, chose très en avance sur son temps, comparé au PC ou aux autres consoles ! Le GPU est construit autour d'un processeur dédié aux calculs géométriques, le ''Reality Signal Processor'' (RSP), autour duquel on a ajouté des circuits pour le reste du pipeline graphique. L'unité de calcul géométrique est un processeur MIPS R4000, un processeur assez courant à l'époque, auquel on avait retiré quelques fonctionnalités inutiles pour le rendu 3D. Il était couplé à 4 KB de mémoire vidéo, ainsi qu'à 4 KB de mémoire ROM. Le reste du GPU était réalisé avec des circuits fixes. Un point intéressant est que le programme exécuté par le RSP pouvait être programmé ! Le RSP gérait déjà des espèces de proto-shaders, qui étaient appelés des ''[https://ultra64.ca/files/documentation/online-manuals/functions_reference_manual_2.0i/ucode/microcode.html micro-codes]'' dans la documentation de l'époque. La ROM associée au RSP mémorise cinq à sept programmes différents, aux fonctionnalités différentes. * Les microcodes gspFast3D et gspF3DNoN, implémentent un rendu 3D normal, avec des options de ''clipping'' différentes entre les deux. * Le microcode gspTurbo3D fait la même chose, mais avec moins de fonctionnalités et avec une précision réduite. Il ne gère pas le ''clipping'', l'éclairage par pixel, la correction de perspective, l'antialiasing et quelques autres fonctionnalités. Il gère cependant l'éclairage de Gouraud. Il utilise une ''display list'' simplifiée comparé aux deux microcodes précédents. * Le microcode gspZ-Sort effectue une pré-passe z, à savoir qu'il calcule le tampon de profondeur final de la scène 3D, sans rendre l'image. Cela sert à faire une élimination des pixels cachés parfaite, en logiciel. On calcule le tampon de profondeur pour déterminer quels pixels sont visibles, puis une seconde passe rend l'image en, rejetant les pixels non-visibles. * Le microcode gspSprite2D implémente un rendu 2D émulé : les sprites et arrière-plan sont des rectangles texturés. Le microcode gspS2DEX fait la même chose, mais sert à émuler le rendu de la SNES plus qu'autre chose. * Le microcode gspLine3D ne gére que des lignes, pas de triangles. Il sert pour du rendu en fil de fer. Ils géraient le rendu 3D de manière différente et avec une gestion des ressources différentes. Très peu de studios de jeu vidéo ont développé leur propre microcodes N64, car la documentation était mal faite, que Nintendo ne fournissait pas de support officiel pour cela, que les outils de développement ne permettaient pas de faire cela proprement et efficacement. ===La Playstation 1=== Sur la Playstation 1 le calcul de la géométrie était réalisé par le processeur, la carte graphique gérait tout le reste. Et la carte graphique était un circuit fixe spécialisé dans la rasterisation et le placage de textures. Elle utilisait, comme la Nintendo 64, le placage de texture inverse, qui est apparu ensuite sur les cartes graphiques. ===La 3DO et la Sega Saturn=== La Sega Saturn et la 3DO étaient les deux seules consoles à utiliser le rendu direct. La géométrie était calculée sur le processeur, même si les consoles utilisaient parfois un CPU dédié au calcul de la géométrie. Le reste du pipeline était géré par un VDC 2D qui implémentait le placage de textures. La Sega Saturn incorpore trois processeurs et deux GPU. Les deux GPUs sont nommés le VDP1 et le VDP2. Le VDP1 s'occupe des textures et des sprites, le VDP2 s'occupe uniquement de l'arrière-plan et incorpore un VDC tout ce qu'il y a de plus simple. Ils ne gèrent pas du tout la géométrie, qui est calculée par les trois processeurs. Le troisième processeur, la Saturn Control Unit, est un processeur de type DSP, à savoir un processeur spécialisé dans le traitement de signal. Il est utilisé presque exclusivement pour accélérer les calculs géométriques. Il avait sa propre mémoire RAM dédiée, 32 KB de SRAM, soit une mémoire locale très rapide. Les transferts entre cette RAM et le reste de l'ordinateur était géré par un contrôleur DMA intégré dans le DSP. En somme, il s'agit d'une sorte de processeur spécialisé dans la géométrie, une sorte d'unité géométrique programmable. Mais la géométrie n'était pas forcément calculée que sur ce DSP, mais pouvait être prise en charge par les 3 CPU. ==L'historique des cartes graphiques pour PC== Sur PC, l'évolution des cartes graphiques a eu du retard par rapport aux consoles. Les PC sont en effet des machines multi-usage, pour lesquelles le jeu vidéo était un cas d'utilisation parmi tant d'autres. Et les consoles étaient la plateforme principale pour jouer à des jeux vidéo, le jeu vidéo PC étant plus marginal. Mais cela ne veut pas dire que le jeu PC n'existait pas, loin de là ! Un problème pour les jeux PC était que l'écosystème des PC était aussi fragmenté en plusieurs machines différentes : machines Apple 1 et 2, ordinateurs Commdore et Amiga, IBM PC et dérivés, etc. Aussi, programmer des jeux PC n'était pas mince affaire, car les problèmes de compatibilité étaient légion. C'est seulement quand la plateforme x86 des IBM PC s'est démocratisée que l'informatique grand public s'est standardisée, réduisant fortement les problèmes de compatibilité. Mais cela n'a pas suffit, il a aussi fallu que les API 3D naissent. Les API 3D comme Direct X et Open GL sont absolument cruciales pour garantir la compatibilité entre plusieurs ordinateurs aux cartes graphiques différentes. Aussi, l'évolution des cartes graphiques pour PC s'est faite main dans la main avec l'évolution des API 3D. Les fonctionnalités des cartes graphiques ont évolué dans le temps, en suivant les évolutions des API 3D. Du moins dans les grandes lignes, car il est arrivé plusieurs fois que des fonctionnalités naissent sur les cartes graphiques, pour que les fabricants forcent la main de Microsoft ou d'Open GL pour les intégrer de force dans les API 3D. Passons. ===L'introduction des premiers jeux 3D : Quake et les drivers miniGL=== L'API OpenGL est née de la main de SGI, encore eux ! SGI avait créé l'API Iris GL pour ses stations de travail Iris Graphics. Iris GL a ensuite été libéré et est devenu le standard Open GL. Open GL existait déjà avant l'apparition des cartes accélératrices 3D. Il y a avait donc déjà un terreau que les programmeurs graphiques pouvaient utiliser. Mais Open GL était surtout utilisé pour des applications industrielles, médicales (imagerie), graphiques ou militaires, pas pour le jeu vidéo. Mais cela changea avec la sortie du jeu Quake, d'IdSoftware, en 1996. Quake pouvait fonctionner en rendu logiciel, mais le programmeur responsable du moteur 3D (le célébre John Carmack) ajouta une version OpenGL du jeu. Il faut dire que le jeu était programmé sur une station de travail compatible avec OpenGL, même si aucune carte accélératrice de l'époque ne supportait OpenGL. C'était là un choix qui se révéla visionnaire. En théorie, le rendu par OpenGL aurait dû se faire intégralement en logiciel, sauf sur quelques rares stations de travail adaptées. Mais les premières cartes graphiques étaient déjà dans les starting blocks. La toute première carte 3D pour PC est la '''Rendition Vérité V1000''', sortie en Septembre 1995, soit quelques mois avant l'arrivée de la Nintendo 64. La Rendition Vérité V1000 contenait un processeur MIPS cadencé à 25 MHz, 4 mébioctets de RAM, une ROM pour le BIOS, et un RAMDAC, rien de plus. C'était un vrai ordinateur complètement programmable de bout en bout, sans aucun circuit fixe. Les programmeurs ne pouvaient cependant pas utiliser cette programmabilité avec des ''shaders'', mais elle permettait à Rendition d'implémenter n'importe quelle API 3D, que ce soit OpenGL, DirectX ou même sa son API propriétaire. La Rendition Vérité avait de bonnes performances pour ce qui est de la géométrie, mais pas pour le reste. Réaliser la rastérisation et le placage de texture en logiciel n'est pas efficace, pareil pour les opérations de fin de pipeline comme l'antialiasing. Le manque d'unités fixes très rapides pour la rastérisation, le placage de texture ou les opérations de fin de pipeline était clairement un gros défaut. Mais la Rendition Vérité était un cas à part, une exception dans le paysage des cartes 3D de l'époque, qui ne faisait rien comme les autres. Les autres cartes graphiques, sorties peu après, étaient les Voodoo de 3dfx, les Riva TNT de NVIDIA, les Rage/3D d'ATI, la Virge/3D de S3, et la Matrox Mystique. Elles avaient choisit le compromis inverse de la Rendition Vérité V1000 : de bonnes performances pour le placage de textures et la rastérization, mais pas pour les calculs géométriques. Pour rappel, les systèmes professionnels et les consoles avaient des processeurs pour la géométrie, et des circuits fixes pour le reste. Les cartes graphiques de PC se passaient des processeurs pour la géométrie, les calculs géométriques étaient réalisés par le CPU. Les toutes premières cartes 3D pour PC contenaient seulement des circuits pour gérer les textures et des ROPs. Elle géraient le ''z-buffer'' en mémoire vidéo, ainsi que des effets de brouillard. Il n'y avait même pas de circuit pour la rastérisation, qui était faite en logiciel, avec les calculs géométriques. [[File:Architecture de base d'une carte 3D - 2.png|centre|vignette|upright=1.5|Carte 3D sans rasterization matérielle.]] Les cartes suivantes ajoutèrent une gestion des étapes de ''rasterization'' directement en matériel. Les cartes ATI rage 2, les Invention de chez Rendition, et d'autres cartes graphiques supportaient la rasterisation en hardware. [[File:Architecture de base d'une carte 3D - 3.png|centre|vignette|upright=1.5|Carte 3D avec gestion de la géométrie.]] Pour exploiter les unités de texture et le circuit de rastérisation, OpenGL et Direct 3D étaient partiellement implémentées en logiciel, car les cartes graphiques ne supportaient pas toutes les fonctionnalités de l'API. C'était l'époque du miniGL, des implémentations partielles d'OpenGL, fournies par les fabricants de cartes 3D, implémentées dans les pilotes de périphériques de ces dernières. Les fonctionnalités d'OpenGL implémentées dans ces pilotes étaient presque toutes exécutées en matériel, par la carte graphique. Avec l'évolution du matériel, les pilotes de périphériques devinrent de plus en plus complets, au point de devenir des implémentations totales d'OpenGL. Mais au-delà d'OpenGL, chaque fabricant de carte graphique avait sa propre API propriétaire, qui était gérée par leurs pilotes de périphériques (''drivers''). Par exemple, les premières cartes graphiques de 3dfx interactive, les fameuses voodoo, disposaient de leur propre API graphique, l'API Glide. Elle facilitait la gestion de la géométrie et des textures, ce qui collait bien avec l'architecture de ces cartes 3D. Mais ces API propriétaires tombèrent rapidement en désuétude avec l'évolution de DirectX et d'OpenGL. Direct X était une API dans l'ombre d'Open GL. La première version de Direct X qui supportait la 3D était DirectX 2.0 (juin 2, 1996), suivie rapidement par DirectX 3.0 (septembre 1996). Elles dataient d'avant le jeu Quake, et elles étaient très éloignées du hardware des premières cartes graphiques. Elles utilisaient un système d'''execute buffer'' pour communiquer avec la carte graphique, Microsoft espérait que le matériel 3D implémenterait ce genre de système. Ce qui ne fu pas le cas. Direct X 4.0 a été abandonné en cours de développement pour laisser à une version 5.0 assez semblable à la 2.0/3.0. Le mode de rendu laissait de côté les ''execute buffer'' pour coller un peu plus au hardware de l'époque. Mais rien de vraiment probant comparé à Open GL. Même Windows utilisait Open GL au lieu de Direct X maison... C'est avec Direct X 6.0 que Direct X est entré dans la cours des grands. Il gérait la plupart des technologies supportées par les cartes graphiques de l'époque. ===Le ''multi-texturing'' de l'époque Direct X 6.0 : combiner plusieurs textures=== Une technologie très importante standardisée par Dirext X 6 est la technique du '''''multi-texturing'''''. Avec ce qu'on a dit dans le chapitre précédent, vous pensez sans doute qu'il n'y a qu'une seule texture par objet, qui est plaquée sur sa surface. Mais divers effet graphiques demandent d'ajouter des textures par dessus d'autres textures. En général, elles servent pour ajouter des détails, du relief, sur une surface pré-existante. Un exemple intéressant vient des jeux de tir : ajouter des impacts de balles sur les murs. Pour cela, on plaque une texture d'impact de balle sur le mur, à la position du tir. Il s'agit là d'un exemple de '''''decals''''', des petites textures ajoutées sur les murs ou le sol, afin de simuler de la poussière, des impacts de balle, des craquelures, des fissures, des trous, etc. Les textures en question sont de petite taille et se superposent à une texture existante, plus grande. Rendre des ''decals'' demande de pouvoir superposer deux textures. Direct X 6.0 supportait l'application de plusieurs textures directement dans le matériel. La carte graphique devait être capable d'accéder à deux textures en même temps, ou du moins faire semblant que. Pour cela, elle doublaient les unités de texture et adaptaient les connexions entre unités de texture et mémoire vidéo. La mémoire vidéo devait être capable de gérer plusieurs accès mémoire en même temps et devait alors avoir un débit binaire élevé. [[File:Multitexturing.png|centre|vignette|upright=2|Multitexturing]] La carte graphique devait aussi gérer de quoi combiner deux textures entre elles. Par exemple, pour revenir sur l'exemple d'une texture d'impact de balle, il faut que la texture d'impact recouvre totalement la texture du mur. Dans ce cas, la combinaison est simple : la première texture remplace l'ancienne, là où elle est appliquée. Mais les cartes graphiques ont ajouté d'autres combinaisons possibles, par exemple additionner les deux textures entre elle, faire une moyenne des texels, etc. Les opérations pour combiner les textures était le fait de circuits appelés des '''''combiners'''''. Concrètement, les ''combiners'' sont de simples unités de calcul. Les ''conbiners'' ont beaucoup évolués dans le temps, mais les premières implémentation se limitaient à quelques opérations simples : addition, multiplication, superposition, interpolation. L'opération effectuer était envoyée au ''conbiner'' sur une entrée dédiée. [[File:Multitexturing avec combiners.png|centre|vignette|upright=2|Multitexturing avec combiners]] S'il y avait eu un seul ''conbiner'', le circuit de ''multitexturing'' aurait été simplement configurable. Mais dans la réalité, les premières cartes utilisant du ''multi-texturing'' utilisaient plusieurs ''combiners'' placés les uns à la suite des autres. L'implémentation des ''combiners'' retenue par Open Gl, et par le hardware des cartes graphiques, était la suivante. Les ''combiners'' étaient placés en série, l'un à la suite de l'autre, chacun combinant le résultat de l'étage précédent avec une texture. Le premier ''combiner'' gérait l'éclairage par sommet, afin de conserver un minimum de rétrocompatibilité. [[File:Texture combiners Open GL.png|centre|vignette|upright=2|Texture combiners Open GL]] Voici les opérations supportées par les ''combiners'' d'Open GL. Ils prennent en entrée le résultat de l'étage précédent et le combinent avec une texture lue depuis l'unité de texture. {|class="wikitable" |+ Opérations supportées par les ''combiners'' d'Open GL |- ! Replace | colspan="2" | Pixel provenant de l'unité de texture |- ! Addition | colspan="2" | Additionne l'entrée au texel lu. |- ! Modulate | colspan="2" | Multiplie l'entrée avec le texel lu |- ! Mélange (''blending'') | Moyenne pondérée des deux entrées, pondérée par la composante de transparence || La couleur de transparence du texel lu et de l'entrée sont multipliées. |- ! Decals | Moyenne pondérée des deux entrées, pondérée par la composante de transparence. || La transparence du résultat est celle de l'entrée. |} Il faut noter qu'un dernier étage de ''combiners'' s'occupait d'ajouter la couleur spéculaire et les effets de brouillards. Il était à part des autres et n'était pas configurable, c'était un étage fixe, qui était toujours présent, peu importe le nombre de textures utilisé. Il était parfois appelé le '''''combiner'' final''', terme que nous réutiliserons par la suite. Mine de rien, cela a rendu les cartes graphiques partiellement programmables. Le fait qu'il y ait des opérations enchainées à la suite, opérations qu'on peut choisir librement, suffit à créer une sorte de mini-programme qui décide comment mélanger plusieurs textures. Mais il y avait une limitation de taille : le fait que les données soient transmises d'un étage à l'autre, sans détours possibles. Par exemple, le troisième étage ne pouvait avoir comme seule opérande le résultat du second étage, mais ne pouvait pas utiliser celui du premier étage. Il n'y avait pas de registres pour stocker ce qui sortait de la rastérisation, ni pour mémoriser temporairement les texels lus. ===Le ''Transform & Lighting'' matériel de Direct X 7.0=== La première carte graphique pour PC capable de gérer la géométrie en hardware fût la Geforce 256, la toute première Geforce. Son unité de gestion de la géométrie n'est autre que la bien connue '''unité T&L''' (''Transform And Lighting''). Elle implémentait des algorithmes d'éclairage de la scène 3D assez simples, comme un éclairage de Gouraud, qui étaient directement câblés dans ses circuits. Mais contrairement à la Nintendo 64 et aux bornes d'arcade, elle implémentait le tout, non pas avec un processeur classique, mais avec des circuits fixes. Avec Direct X 7.0 et Open GL 1.0, l'éclairage était en théorie limité à de l'éclairage par sommet, l'éclairage par pixel n'était pas implémentable en hardware. Les cartes graphiques ont tenté d'implémenter l'éclairage par pixel, mais cela n'est pas allé au-delà du support de quelques techniques de ''bump-mapping'' très limitées. Par exemple, Direct X 6.0 implémentait une forme limitée de ''bump-mapping'', guère plus. Un autre problème est qu'il a beaucoup d'algorithmes d'éclairages différents, aux résultats visuels différents, bien au-delà des algorithmes d'éclairage plat, de Gouraud et de Phong. Et les unités de T&L étaient souvent en retard sur les algorithmes logiciels. Les programmeurs avaient le choix entre programmer les algorithmes d’éclairage qu'ils voulaient et les exécuter en logiciel, ou utiliser ceux de l'unité de T&L. Ils choisissaient souvent la première option. Par exemple, Quake 3 Arena et Unreal Tournament n'utilisaient pas les capacités d'éclairage géométrique et préféraient utiliser leurs calculs d'éclairage logiciel fait maison. Cependant, le hardware dépassait les capacités des API et avait déjà commencé à ajouter des capacités de programmation liées au ''multi-texturing''. Les cartes graphiques de l'époque, surtout chez NVIDIA, implémentaient un système de '''''register combiners''''', une forme améliorée de ''texture combiners'', qui permettait de faire une forme limitée d'éclairage par pixel, notamment du vrai ''bump-mampping'', voire du ''normal-mapping''. Mais ce n'était pas totalement supporté par les API 3D de l'époque. Les ''registers combiners'' sont des ''texture combiners'' mais dans lesquels ont aurait retiré la stricte organisation en série. Il y a toujours plusieurs étages à la suite, qui peuvent exécuter chacun une opération, mais tous les étages ont maintenant accès à toutes les textures lues et à tout ce qui sort de la rastérisation, pas seulement au résultat de l'étape précédente. Pour cela, on ajoute des registres pour mémoriser ce qui sort des unités de texture, et pour ce qui sort de la rastérisation. De plus, on ajoute des registres temporaires pour mémoriser les résultats de chaque ''combiner'', de chaque étage. Il faut cependant signaler qu'il existe un ''combiner'' final, séparé des étages qui effectuent des opérations proprement dits. Il s'agit de l'étage qui applique la couleur spéculaire et les effets de brouillards. Il ne peut être utilisé qu'à la toute fin du traitement, en tant que dernier étage, on ne peut pas mettre d'opérations après lui. Sa sortie est directement connectée aux ROPs, pas à des registres. Il faut donc faire la distinction entre les '''''combiners'' généraux''' qui effectuent une opération et mémorisent le résultat dans des registres, et le ''combiner'' final qui envoie le résultat aux ROPs. L'implémentation des ''register combiners'' utilisait un processeur spécialisés dans les traitements sur des pixels, une sorte de proto-processeur de ''shader''. Le processeur supportait des opérations assez complexes : multiplication, produit scalaire, additions. Il s'agissait d'un processeur de type VLIW, qui sera décrit dans quelques chapitres. Mais ce processeur avait des programmes très courts. Les premières cartes NVIDIA, comme les cartes TNT pouvaient exécuter deux opérations à la suite, suivie par l'application de la couleurs spéculaire et du brouillard. En somme, elles étaient limitées à un ''shader'' à deux/trois opérations, mais c'était un début. Le nombre d'opérations consécutives est rapidement passé à 8 sur la Geforce 3. [[File:Architecture de base d'une carte 3D - 4.png|centre|vignette|upright=1.5|Carte 3D avec gestion de la géométrie.]] ===L'arrivée des ''shaders'' avec Direct X 8.0=== Les ''register combiners'' était un premier pas vers un éclairage programmable. Paradoxalement, l'évolution suivante s'est faite non pas dans l'unité de rastérisation/texture, mais dans l'unité de traitement de la géométrie. La Geforce 3 a remplacé l'unité de T&L par un processeur capable d'exécuter des programmes. Les programmes en question complétaient l'unité de T&L, afin de pouvoir rajouter des techniques d'éclairage plus complexes. Le tout a permis aussi d'ajouter des animations, des effets de fourrures, des ombres par ''shadow volume'', des systèmes de particule évolués, et bien d'autres. À partir de la Geforce 3 de Nvidia, les cartes graphiques sont devenues capables d'exécuter des programmes appelés '''''shaders'''''. Le terme ''shader'' vient de ''shading'' : ombrage en anglais. Grace aux ''shaders'', l'éclairage est devenu programmable, il n'est plus géré par des unités d'éclairage fixes mais été laissé à la créativité des programmeurs. Les programmeurs ne sont plus vraiment limités par les algorithmes d'éclairage implémentés dans les cartes graphiques, mais peuvent implémenter les algorithmes d'éclairage qu'ils veulent et peuvent le faire exécuter directement sur la carte graphique. Les ''shaders'' sont classifiés suivant les données qu'ils manipulent : '''''pixel shader''''' pour ceux qui manipulent des pixels, '''''vertex shaders''''' pour ceux qui manipulent des sommets. Les premiers sont utilisés pour implémenter l'éclairage par pixel, les autres pour gérer tout ce qui a trait à la géométrie, pas seulement l'éclairage par sommets. Direct X 8.0 avait un standard pour les shaders, appelé ''shaders 1.0'', qui correspondait parfaitement à ce dont était capable la Geforce 3. Il standardisait les ''vertex shaders'' de la Geforce 3, mais il a aussi renommé les ''register combiners'' comme étant des ''pixel shaders'' version 1.0. Les ''register combiners'' n'ont pas évolués depuis la Geforce 256, si ce n'est que les programmes sont passés de deux opérations successives à 8, et qu'il y avait possibilité de lire 4 textures en ''multitexturing''. A l'opposé, le processeur de ''vertex shader'' de la Geforce 3 était capable d'exécuter des programmes de 128 opérations consécutives et avait 258 registres différents ! Des ''pixels shaders'' plus évolués sont arrivés avec l'ATI Radeon 8500 et ses dérivés. Elle incorporait la technologie ''SMARTSHADER'' qui remplacait les ''registers combiners'' par un processeur de ''shader'' un peu limité. Un point est que le processeur acceptait de calculer des adresses de texture dans le ''pixel shader''. Avant, les adresses des texels à lire étaient fournis par l'unité de rastérisation et basta. L'avantage est que certains effets graphiques étaient devenus possibles : du ''bump-mapping'' avancé, des textures procédurales, de l'éclairage par pixel anisotrope, du éclairage de Phong réel, etc. Avec la Radeon 8500, le ''pixel shader'' pouvait calculer des adresses, et lire les texels associés à ces adresses calculées. Les ''pixel shaders'' pouvaient lire 6 textures, faire 8 opérations sur les texels lus, puis lire 6 textures avec les adresses calculées à l'étape précédente, et refaire 8 opérations. Quelque chose de limité, donc, mais déjà plus pratique. Les ''pixel shaders'' de ce type ont été standardisé dans Direct X 8.1, sous le nom de ''pixel shaders 1.4''. Encore une fois, le hardware a forcé l'intégration dans une API 3D. [[File:Architecture de la Geforce 3.png|centre|vignette|upright=1.5|Architecture de la Geforce 3]] ===Les ''shaders'' de Direct X 9.0 : de vrais ''pixel shaders''=== Avec Direct X 9.0, les ''shaders'' sont devenus de vrais programmes, sans les limitations des ''shaders'' précédents. Les ''pixels shaders'' sont passés à la version 2.0, idem pour les ''vertex shaders''. Concrètement, ils ont des fonctionnalités bien supérieures à celles des ''registers combiners''. Les ''shaders'' pouvaient exécuter une suite d'opérations arbitraire, dans le sens où elle n'était pas structurée avec tel type d'opération au début, suivie par un accès aux textures, etc. On pouvait mettre n'importe quelle opération dans n'importe quel ordre. De plus, les ''shaders'' ne sont plus écrit en assembleur comme c'était le cas avant. Ils sont dorénavant écrits dans un langage de haut-niveau, le HLSL pour les shaders Direct X et le GLSL pour les shaders Open Gl. Les ''shaders'' sont ensuite traduit (compilés) en instructions machines compréhensibles par la carte graphique. Au début, ces langages et la carte graphique supportaient uniquement des opérations simples. Mais au fil du temps, les spécifications de ces langages sont devenues de plus en plus riches à chaque version de Direct X ou d'Open Gl, et le matériel en a fait autant. Le matériel s'est alors adapté, en incorporant un véritable processeur pour les ''pixel shaders''. Les ''pixel shaders'' sont maintenant exécutés par un processeur de ''shader'' dédié, aux fonctionnalités bien supérieures à celles des ''registers combiners''. Le processeur de ''pixel shader'' incorpore l'unité de texture en sont sein, les deux sont fusionnés. La raison à cela sera expliqué dans la suite du chapitre. [[File:Architecture de base d'une carte 3D - 5.png|centre|vignette|upright=1.5|Carte 3D avec pixels et vertex shaders non-unifiés.]] ===L'après Direct X 9.0 : GPGPU et shaders unifiés=== Avant Direct X 10, les processeurs de ''shaders'' ne géraient pas exactement les mêmes opérations pour les processeurs de ''vertex shader'' et de ''pixel shader''. Les processeurs de ''vertex shader'' et de ''pixel shader''étaient séparés. Depuis DirectX 10, ce n'est plus le cas : le jeu d'instructions a été unifié entre les vertex shaders et les pixels shaders, ce qui fait qu'il n'y a plus de distinction entre processeurs de vertex shaders et de pixels shaders, chaque processeur pouvant traiter indifféremment l'un ou l'autre. [[File:Architecture de base d'une carte 3D - 6.png|centre|vignette|upright=1.5|Architecture de la GeForce 6800.]] Les GPU modernes sont capables d’exécuter des programmes informatiques qui n'ont aucun lien avec le rendu 3D, comme des calculs scientifiques, tout ce qui implique des réseaux de neurones, de l'imagerie médicale, etc. De manière générale, tout calcul faisant usage d'un grand nombre de calculs sur des matrices ou des vecteurs est concerné. L'usage d'une carte graphique pour autre chose que le rendu 3D porte le nom de '''GPGPU''', ''General Processing GPU''. En soi, le GPGPU est assez logique : les processeurs de shaders, bien que conçus avec le rendu 3D en tête, n'en restent pas moins des processeurs assez puissants. Pour ce genre d'utilisations, les GPU actuel supportent des ''shaders'' sans lien avec le rendu 3D, appelés des ''compute shader''. ==Les cartes graphiques d'aujourd'hui== Les circuits d'un GPU ont beaucoup évolué depuis l'introduction des ''shaders'', pour devenir de plus en plus programmables. Mais à côté des processeurs de ''shaders'', il reste quelques circuits non-programmables appelés des circuits fixes. La rastérisation, le placage de texture, l'élimination des pixels cachés et le mélange ''alpha'' sont gérés par des circuits fixes. [[File:3D-Pipeline.svg|centre|vignette|upright=3.0|Pipeline 3D : ce qui est programmable et ce qui ne l'est pas dans une carte graphique moderne.]] Mais pourquoi ne pas tout rendre programmable ? Ou au contraire, utiliser seulement des circuits fixes ? La réponse n'est pas la même pour les ROPs, le rastériseur, et les unités de texture. Pour simplifier, la réponse rapide est qu'il s'agit d'un compromis entre flexibilité et performance qui permet d'avoir le meilleur des deux mondes. Mais ce compromis a fortement évolué dans le temps, comme on va le voir plus bas. Rendre l'éclairage programmable permet d'implémenter facilement un grand nombre d'effets graphiques sans avoir à les implémenter en hardware. Avant les ''shaders'', les effets graphiques derniers cri n'étaient disponibles que sur les derniers modèles de carte graphique. Avec des ''vertex/pixel shaders'', ce genre de défaut est passé à la trappe. Si un nouvel algorithme de rendu graphique est inventé, il peut être utilisé dès le lendemain sur toutes les cartes graphiques modernes. De plus, implémenter beaucoup d'algorithmes d'éclairage différents avec des circuits fixes a un cout en termes de transistors, alors qu'utiliser des circuits programmable a un cout en hardware plus limité. Tout cela est à l'exact opposé de ce qu'on a avec les autres circuits, comme les circuits pour la rastérisation ou le placage de texture. Il n'y a pas 36 façons de rastériser une scène 3D et la flexibilité n'est pas un besoin important pour cette opération, alors que les performances sont cruciales. Même chose pour le placage/filtrage de textures. En conséquences, les unités de rastérisation et de texture sont toutes implémentées en matériel. Faire ainsi permet de gagner en performance sans que cela ait le moindre impact pour le programmeur. Reste à expliquer dans le détail pourquoi. Le cas du ROP est plus complexe et on en reparlera dans un chapitre dédié. Mais pour simplifier, c'est parce que les GPU actuels sont de type ''sort-last'', comme vu dans le chapitre précédent. Il trient les pixels suivant leur position à l'écran à la toute fin du pipeline, et ce tri ne peut pas être rendu programmable. ===Les unités de texture sont intégrées aux processeurs de shaders=== Avec l'arrivée des processeurs de shaders, les unités de texture ont été intégrées dans les processeurs de shaders eux-mêmes. C'est la seule unité fixe qui a subit ce traitement, et il est intéressant de comprendre pourquoi. [[File:Architecture de base d'une carte 3D - 5.png|centre|vignette|upright=2|Architecture de base d'une carte 3D.]] Pour cela, il faut faire un rappel sur ce qu'il y a dans un processeur. Un processeur contient globalement quatre circuits : * une unité de calcul qui fait des calculs ; * des registres pour stocker les opérandes et résultats des calculs ; * une unité de communication avec la mémoire ; * et un séquenceur, un circuit de contrôle qui commande les autres. L'unité de communication avec la mémoire sert à lire ou écrire des données, à les transférer de la RAM vers les registres, ou l'inverse. Lire une donnée demande d'envoyer son adresse à la RAM, qui répond en envoyant la donnée lue. Elle est donc toute indiquée pour lire une texture : lire une texture n'est qu'un cas particulier de lecture de données. Les texels à lire sont à une adresse précise, la RAM répond à la lecture avec le texel demandé. Il est donc possible d'utiliser l'unité de communication avec la mémoire comme si c'était une unité de texture. Cependant, les textures ne sont pas utilisées comme telles de nos jours. Le rendu 3D moderne utilise des techniques dites de filtrage de texture, qui permettent d'améliorer la qualité du rendu des textures. Sans ce filtrage de texture, les textures appliquées naïvement donnent un résultat assez pixelisé et assez moche, pour des raisons assez techniques. Le filtrage élimine ces artefacts, en utilisant une forme d'''antialiasing'' interne aux textures, le fameux filtrage de texture. Le filtrage de texture peut être réalisé en logiciel ou en matériel. Techniquement, il est possible de le faire dans un ''shader''. Le ''shader'' calcule les adresses des texels à lire, lit les texels, et effectue ensuite le filtrage avec des opérations de calcul. Mais ce n'est pas ce qui est fait, le filtrage de texture est toujours effectué directement en matériel. La raison est que le filtrage de texture est très simple à implémenter en hardware. Le filtrage bilinéaire ou trilinéaire demande juste des circuits d'interpolation et quelques registres, ce qui est trivial. Et la seconde raison est qu'il n'y a pas 36 façons de filtrer des textures : une carte graphique peut implémenter les algorithmes principaux existants en assez peu de circuits. Pour simplifier l'implémentation, les processeurs de ''shader'' modernes disposent d'une unité d'accès mémoire séparée de l'unité de texture. L'unité d'accès mémoire normale s'occupe des accès mémoire hors-textures, alors que l'unité mémoire s'occupe de lire les textures. L'unité de texture contient de quoi faire du filtrage de texture, mais aussi faire des calculs d'adresse spécialisées, intrinsèquement liés au format des textures, qu'on détaillera dans le chapitre sur les textures. En comparaison, les unités d'accès mémoire effectuent des calculs d'adresse plus basiques. Un dernier avantage est que l'unité de texture est reliée au cache de texture, alors que l'unité d'accès mémoire est relié au cache L1/L2. ===Le projet Larrabee d'Intel : une programmabilité maximale=== Pour finir, nous allons parler d'un ancien projet d'Intel, qui ne s'est pas matérialisé : le projet Larrabee. Il s'agissait d'un projet de GPU, qui a été annulé en 2009 avant d'être commercialisé. Le GFU avait pour particularité de limiter les circuits fixes au minimum. Il ne gardait qu'une unité de texture, les ROPs et le rastériseur étaient émulés en logiciel. L'unité de texture n'était pas intégrée aux processeurs de shader, mais en était séparée. Le GPU était composé de plusieurs centaines de processeurs, reliés entre eux avec un réseau d'interconnexion assez complexe. L'unité de texture était connectée sur ce réseau d'interconnexion, de même que le VDC et l'interface avec le bus. [[File:Larrabee slide block diagram.svg|centre|vignette|upright=2.5|Larrabee, diagramme. Les processeurs de shaders sont en orange.]] Un autre point important est que les processeurs utilisés étaient des processeurs x86, les mêmes que ceux utilisés comme CPU dans nos PCs. Le choix d'utiliser des CPU x86 peut sembler étrange, ceux-ci ayant des instructions qui ne servaient à rien pour le rendu 3D, mais qui consommaient une partie du budget en transistors. Mais cela se comprend quand on sait que le GPU était prévu à la fois pour le GPGPU et le rendu 3D. Utiliser des processeurs x86 était très intéressant pour le GPGPU, cela assurait une certaine forme de compatibilité, sans compter que les programmeurs PC sont familiers avec le x86. Pour gérer le problème mentionné plus haut avec les ROPs, Larrabee simulait un GPU de type ''Tile Based Rendering'', où l'écran est divisé en ''tiles'', et la rastérisation se fait ''tile'' par ''tile''. L'émulation logicielle des ROPs était nettement plus simple avec ce genre d'émulation. Mais le logiciel qui émulait les ROPs et le rastériseur était programmé pour éviter ce genre de problèmes. Le projet a été annulé en 2009, sans doute parce qu'il n’arrivait pas à obtenir des performances acceptables. Mais larrabee été recyclé pour donner les Xeon Phi, des cartes d'extension utilisées pour des serveurs, du calcul scientifique ou intensif, ou d'autres usages. Les circuits de rendu 3D avaient été retirées de ces cartes, qui ne faisaient que du calcul. <noinclude> {{NavChapitre | book=Les cartes graphiques | prev=Avant les GPUs : les cartes accélératrices 3D | prevText=Avant les GPUs : les cartes accélératrices 3D | next=Les processeurs de shaders | nextText=Les processeurs de shaders }}{{autocat}} </noinclude> imms89y1pn0nxni8wg3u711cs2xgkp8 Les cartes graphiques/Les processeurs de shaders 0 69558 765152 764882 2026-04-26T20:15:17Z Mewtow 31375 765152 wikitext text/x-wiki Les '''''shaders''''' sont des programmes informatiques exécutés par la carte graphique, et plus précisément par des processeurs de ''shaders''. Un point très important à comprendre est que chaque triangle ou pixel d'une scène 3D peut être traité indépendamment des autres. Le tout se résume comme suit : : '''L’exécution d'un shader génère un grand nombre d'instances de ce shader, chacune traitant un paquet de pixels/sommets différent.''' En conséquence, il est possible de traiter chaque instance d'un ''shader'' en parallèle des autres, en même temps, au lieu de traiter les instances l'une après l'autre. La conséquence est que les cartes graphiques sont des architectures massivement parallèles, à savoir qu'elles sont capables d'effectuer un grand nombre de calculs indépendants en même temps. De plus, le parallélisme utilisé est du parallélisme de données, à savoir qu'on exécute le même programme sur des données différentes, chaque donnée étant traitée en parallèle des autres. Les cartes graphiques récentes incorporent toutes les techniques de parallélisme de donnée au niveau matériel, et nous allons toutes les détailler dans ce chapitre. S'il fallait résumer, elles ont plusieurs processeurs/cœurs, chaque cœur est capable d’exécuter des instructions SIMD (ils ne font que cela, à vrai dire), les cœurs sont fortement multithreadés, et j'en passe. Le premier point est qu'une carte graphique contient de nombreux processeurs, qui eux-mêmes contiennent plusieurs unités de calcul. Savoir combien de cœurs contient une carte graphique est cependant très compliqué, car la terminologie utilisée par les fabricants de carte graphique est particulièrement confuse. Il n'est pas rare que ceux-ci appellent cœurs ou processeurs, ce qui correspond en réalité à une unité de calcul d'un processeur normal, sans doute histoire de gonfler les chiffres. Et on peut généraliser à la majorité de la terminologie utilisée par les fabricants, que ce soit pour les termes ''warps processor'', ou autre, qui ne sont pas aisés à interpréter. L'architecture d'une carte graphique récente est illustrée ci-dessous. Rien de bien déroutant pour qui a déjà étudié les architectures à parallélisme de données, mais quelques rappels ou explications ne peuvent pas faire de mal. Le premier point est la présence d'un grand nombre de processeurs/cœurs, les rectangles en bleu/rouge. Chacun d'entre eux contient un grand nombre de circuits de calculs, avec des circuits de calcul simples mais nombreux en rouge, et une unité pour les calculs complexes (trigonométriques, racines carrées, autres) en rouge. Le tout est relié à une hiérarchie mémoire indiquée en vert, comprenant des mémoires locales en complément de la mémoire vidéo principale. Le tout est alimenté par une unité de répartition, le '''''Thread Execution Control Unit''''' en jaune, qui répartit les différentes instances du ''shader'' sur les différents processeurs. Elle est aussi appelée le '''processeur de commandes''', comme nous le verrons dans quelques chapitres. Nous utiliserons le terme processeur de commande dans ce qui suit. [[File:NVIDIA GPU Accelerator Block Diagram.png|centre|vignette|upright=2.5|Ce schéma illustre l'architecture d'un GPU en utilisant la terminologie NVIDIA. Comme on le voit, la carte graphique contient plusieurs cœurs de processeur distincts. Chacun d'entre eux contient plusieurs unités de calcul généralistes, appelées processeurs de threads, qui s'occupent de calculs simples (en bleu). D'autres calculs plus complexes sont pris en charge par une unité de calcul spécialisée (en rouge). Ces cœurs sont alimentés en instructions par le processeur de commandes, ici appelé ''Thread Execution Control Unit'', qui répartit les différents shaders sur chaque cœur. Enfin, on voit que chaque cœur a accès à une mémoire locale dédiée, en plus d'une mémoire vidéo partagée entre tous les cœurs.]] Les portions bleu, jaune et verte du schéma précédent méritent chacune un chapitre séparé. La hiérarchie mémoire en vert fera l'objet d'un chapitre ultérieur. Quant au répartiteur en jaune, il sera détaillé en profondeur dans le prochain chapitre. Dans ce chapitre, nous allons voir comment fonctionnent les processeurs de ''shaders'', la partie bleue. Nous allons voir que ceux-ci ne sont pas très différents des processeurs que l'on trouve dans les ordinateurs normaux, du moins dans les grandes lignes. Ce sont des processeurs séquentiels, qui exécutent des instructions les unes après les autres. Ils ont des instructions machines, des modes d'adressage, un assembleur, des registres et tout ce qui fait qu'un processeur est un processeur. Néanmoins, il y a une différence de taille : ce sont des processeurs adaptés pour effectuer un grand nombre de calculs en parallèle. ==Les registres des processeurs de shaders== Un processeur de shaders contient beaucoup de '''registres généraux''', qui servent un peu à tout. Le programmeur de shader peut les utiliser à loisir. Tout processeur digne de ce nom possède des registres généraux, mais un processeur de ''shader'' dispose aussi de registres spécialisés, qu'on ne trouve que sur les processeurs de ''shaders''. Ils servent à l'interfacer avec le reste du pipeline graphique. Par exemple, des registres pour les textures, d'autres pour recevoir des données du rastériseur, etc. [[File:Architecture carte graphique vertex avec texture.PNG|centre|vignette|upright=2|Architecture carte graphique vertex avec texture]] ===Les registres d'interface avec le pipeline graphique=== Les processeurs de ''vertex shader'' reçoivent des sommets provenant de l'''input assembler'' et envoient leur résultat au rastériseur. Les processeurs de ''pixel shader'' reçoivent des données de l'unité de rastérisation, et envoient un pixel éclairé aux ROPs. Et pour cela, le processeur de shader a des registres dédiés, qui servent d'interface avec le reste du pipeline graphique. Les '''registres de sortie''' sont là où le processeur stocke les résultats à envoyer, soit au rastériseur pour un ''vertex shader'', soit aux ROP pour un ''pixel shader''. Les registres de sorties sont en écriture seule. Pour donner un exemple, les ''vertex shaders'' ont au minimum un registre pour la position du sommet dans l'espace (trois coordonnées), un autre pour la couleur/luminosité du sommet, un autre pour la couleur du brouillard, un autre pour les coordonnées de texture. {|class="wikitable" |+ Registres de sortie des ''pixel/vertex shaders'' |- ! Vertex shader ! Pixel shader |- | Couleur du pixel | Couleur du sommet |- | Profondeur du pixel | Position du sommet |- | rowspan="2" | | Coordonnées de texture du sommet |- | Couleur de brouillard. |} Les '''registres d'entrée''', aussi appelés '''registres d'attributs''', réceptionnent soit les sommets provenant de l'''input assembler'', soit les pixels provenant de l'unité de rastérisation. Les registres d'entrée sont en lecture seule, du point de vue du processeur de shader, ils sont initialisés avant l'exécution de l'instance du ''shader''. Les '''registres de constantes''' mémorisent des constantes utiles pour le ''shader''. Par exemple, pour les ''vertex shaders'', ils stockent les matrices servant aux différentes étapes de transformation ou d'éclairage. La différence avec les registres d'attribut est qu'ils mémorisent des données constantes pour un objet/modèle 3D (pour un ''draw call'', pour être plus précis) : des matrices de transformation, les adresses de texture, et bien d'autres. A l'opposé, les registres d'attributs mémorisent des sommets/pixels qui varient d'une instance de shader à l'autre. Les ''pixel/vertex shaders'' 1.0 ne géraient que des constantes flottantes pour les ''vertex shaders'', entières pour les ''pixel shaders''. Les ''pixel/vertex shaders'' 2.0 et 3.0 ont ajouté des registres de constantes pour les nombres entiers et des opérandes booléennes. Il y en avait 16, comparé aux centaines de registres de constantes flottants. Les constantes entières et booléennes étaient utilisées pour gérer les boucles, guère plus. Avec les ''pixel/vertex shaders'' 4.0 et plus, les registres de constante n'ont plus de type prédéterminé, le programmeur gère ces registres comme il l'entend. L'adressage des registres de constante est quelque peu particulier. Il faut dire qu'il y en a plusieurs milliers sur les processeurs de ''shaders'' modernes, au point qu'il serait plus juste de parler de mémoire RAM des constantes. Les registres de constante sont en effet un ''local store'' un peu spécial, intégré directement dans le processeur. Et le processeur accède à ce ''local store'' en utilisant une mode d'adressage semblable à celui utilisé pour la mémoire, avec un mode d'adressage indirect. L'adresse à lire dans ce ''local store'' est dans un registre, séparé du reste, appelé le '''registre d'adresse de constante'''. ===Les registres spécialisés internes=== D'autres registres spécialisés ne font pas l'interface avec le reste du GPU. Ils servent à stocker des constantes ou des données importantes, qui n'ont pas vraiment leur place dans les registres généraux. Depuis les ''pixel/vertex shaders'' 3.0, les ''shaders'' sont capables d'effectuer des boucles et d'autres structures de contrôle familières pour les programmeurs. Et deux registres ont été intégrés afin d'améliorer les performances des structures de contrôle. Le premier est un registre à prédicat, qui sera vu dans la section sur le SIMD avec prédication. Le second est un '''registre compteur de boucle''', qui mémorise l'indice d'une boucle. Il est initialisé à 0, et est incrémenté à chaque fois qu'une boucle s'exécute. Certains processeurs de shader ont aussi des '''registres de texture''' , qui servent d'interface avec la mémoire pour la gestion des textures. Ils mémorisent les texels lus par l'unité de texture. L'unité de texture lit un texel, plusieurs avec ''multitexturing'', et les place dans ces registres de texture. Les registres de texture sont parfois initialisés avant l'exécution du ''shader'', mais la plupart sont initialisé quand le ''shader'' termine une instruction de lecture de texture. Ils sont généralement en lecture seule, mais il y a des exceptions. ==Les processeurs de shaders modernes : les processeurs SIMD== Maintenant, voyons quelles sont les instructions supportées par les processeurs de shaders modernes. Et si je dis moderne, c'est car nous ne parlerons que des GPU de l'époque DirectX 10 et après, pas des GPU de l'époque DirectX 9 et antérieur. La raison est que le jeu d'instruction des shaders a franchement évolué, avec le passage d'architectures VLIW à des architectures SIMD. Et cela a eu des conséquences assez profondes sur le jeu d'instruction et leur microarchitecture. Nous n'allons parler des GPU de type SIMD dans ce chapitre. Un chapitre dédié sera consacré aux GPU de type VLIW. Le jeu d'instruction des GPU NVIDIA n'est pas encore connu à l'heure où j'écris ces lignes, la documentation du constructeur n'est pas disponible. Quelques chercheurs ont tenté de faire de la rétro-ingénierie du code de divers shaders pour retrouver le jeu d'instruction des divers GPU NVIDIA, ce qui fait qu'on a cependant une idée de ce dernier. Mais rien d'officiel. Par contre, AMD fournit librement cette documentation sur le net. Ce qui fait qu'on peut trouver des documents de ce genre : * [https://developer.amd.com/wordpress/media/2012/12/AMD_Southern_Islands_Instruction_Set_Architecture.pdf Graphics Core Next 1 instruction set] ; * [https://developer.amd.com/wordpress/media/2013/07/AMD_Sea_Islands_Instruction_Set_Architecture.pdf Graphics Core Next 2 instruction set] ; * [https://developer.amd.com/wordpress/media/2013/12/AMD_GCN3_Instruction_Set_Architecture_rev1.1.pdf Graphics Core Next 3 and 4 instruction sets] ; * [https://developer.amd.com/wp-content/resources/Vega_Shader_ISA_28July2017.pdf Graphics Core Next 5 instruction set] ; * [https://developer.amd.com/wp-content/resources/Vega_7nm_Shader_ISA.pdf "Vega" 7nm instruction set architecture] (also referred to as Graphics Core Next 5.1) ; * [https://www.amd.com/content/dam/amd/en/documents/radeon-tech-docs/instruction-set-architectures/rdna3-shader-instruction-set-architecture-feb-2023_0.pdf Jeu d'instruction des GPU de type RDNA3 d'AMD]. ===Les instructions SIMD=== Les '''instructions SIMD''' manipulent plusieurs nombres en même temps. Elles manipulent plus précisément des '''vecteurs''', des ensembles de plusieurs nombres entiers ou nombres flottants placés les uns à côté des autres, le tout ayant une taille fixe, qui sont stockés dans des registres spécialisés. En général, tous les vecteurs ont une taille fixe, peu importe leur contenu. Cela implique que suivant la taille des données à manipuler, on pourra en placer plus ou moins dans un vecteur. Par exemple, un vecteur de 128 bits pourra contenir 4 entiers de 32 bits, 4 flottants 32 bits, ou 8 entiers de 16 bits. [[File:Vector register.png|centre|vignette|upright=2|Contenu d'un vecteur en fonction du type de données utilisé.]] Les vecteurs sont stockés dans des '''registres vectoriels''', aussi appelés '''registres SIMD'''. Un registre vectoriel peut contenir un vecteur complet, pas plus. En conséquence, ils ont une taille assez importante : ils font généralement 128, 256, voire 512 bits, comparé aux 32/64 bits des registres des CPU. Les cartes graphiques modernes contiennent un très grand nombre de registres SIMD. {| |+ Comparaison entre un processeur sans registres vectoriels, et avec registres vectoriels. |[[File:Non-SIMD cpu diagram1.svg|vignette|upright=1.5|CPU Non-SIMD]] |[[File:SIMD cpu diagram1.svg|vignette|upright=1.5|CPU SIMD]] |} Une instruction SIMD traite chaque donnée du vecteur indépendamment des autres. Par exemple, une instruction d'addition vectorielle va additionner ensemble les données qui sont à la même place dans deux vecteurs, et placer le résultat dans un autre vecteur, à la même place. [[File:Instructions SIMD.png|centre|vignette|upright=2.0|Instructions SIMD]] Sur les cartes graphiques modernes, les vecteurs sont généralement des vecteurs qui regroupent plusieurs nombres flottants. De plus, les flottants en question sont des flottants dits simple précision, codés sur 32 bits. Mais il y a quelques exceptions, comme [https://www.realworldtech.com/apple-custom-gpu/ certains GPU d'Apple, qui ne gèrent majoritairement que des flottants codés sur 16 bits], avec des fonctionnalités pour la simple précision. Les anciennes cartes graphiques ne géraient pas du tout de vecteurs contenant des nombres entiers. ===Les instruction scalaires entières, typiques des CPU=== Un processeur SIMD gère donc des instructions SIMD, et les anciennes cartes graphiques ne disposaient que d'instructions de ce type. Mais depuis au moins une décennie, les processeurs de shaders gèrent des instructions normales, non-SIMD. De telles instructions sont appelées des '''instruction scalaires'''. En clair, il s'agit des instructions qu'on retrouve normalement tous les processeurs principaux (les CPU). Il s'agit généralement d''''instructions entières''', agissent sur des registres entiers non-SIMD. Elles ne traitent pas de vecteur, mais de simples nombres entiers indépendants, sans regroupement d'aucune sorte. Typiquement, il s'agit d'opérations d'addition, de soustraction, des opérations logiques, des comparaisons, guère plus. On trouve aussi des opérations un peu originales, comme des calculs de valeur absolue, du minimum/maximum de deux opérandes, des opérations à prédicat comme une instruction CMOV, etc. Les cartes graphiques supportent rarement la multiplication, mais les plus récentes supportent des multiplications sur des opérandes de 16/32 bits. Par contre, aucune ne gère de division entière. Les GPU modernes gèrent aussi des instructions de test et de branchement, là encore sur des nombres entiers. Les instructions de test et branchement sont généralement considérées comme à part des instructions de calcul, mais ce sont des opérations scalaires. Les comparaisons se font entre deux entiers scalaires, pas entre deux vecteurs. Retenez bien ce détail, car il sera très important pour la suite. Les GPU modernes gèrent aussi des '''instructions flottantes scalaires''', à savoir que des instructions qui ont pour opérandes des nombres flottants isolés, qui ne sont pas dans un vecteur. Les processeurs principaux (CPU) d'un ordinateur sont capables de faire beaucoup de calculs arithmétiques simples sur des nombres flottants, comme des additions, des multiplications, des opérations bit-à-bit, éventuellement des divisions, etc. Il en est de même sur les GPUS. Mais ces derniers gèrent aussi de nombreuses instructions flottantes que les CPU n'incorporent presque pas. Il est rare que les CPU soient capables de faire des opérations flottantes complexes, comme des calculs trigonométriques, des exponentielles, des logarithmes, des racines carrées ou racines carrées inverse, etc. De tels calculs sont rares dans les programmes exécutables, alors que les calculs arithmétiques simples y sont légion. Mais le rendu 3D demande pas mal de calculs trigonométriques, de produits scalaires ou d'autres opérations. Par exemple, dans les chapitres précédents, nous avions abordé les calculs d'éclairage et avions vu qu'ils font beaucoup de calculs vectoriels avec des vecteurs comme la normale d'un sommet. Et ces calculs demandent de calculer des produits scalaires et vectoriels, qui eux-mêmes demandent des calculs trigonométriques comme le cosinus ou le sinus. Aussi, les processeurs de ''shaders'' disposent souvent d'instructions flottantes spécialisées dans les calculs complexes : exponentielle/logarithme, racine carrée, racine carrée inverse, autres. Nous appellerons ces instructions des '''instructions transcendantales''', car elles effectuent des calculs de ce type. Il faut noter que le processeur incorpore des registres dédiés aux scalaires, séparés des registres SIMD. Par séparés, on veut dire que ce sont des registres différents, adressés différemment, mais qu'ils sont aussi physiquement séparés dans le processeur, ils sont des bancs de registres différents. ===Les instructions en ''co-issue''=== Beaucoup de cartes graphiques récentes comme anciennes incorporent des '''instructions de ''co-issue''''' qui ne se trouvent que sur les cartes graphiques et n'ont aucun équivalent sur les CPUs. Les instructions de ''co-issue'' regroupent plusieurs opérations par instruction. Par exemple, elles peuvent combiner une opération vectorielle avec une opération scalaire. Ou encore, elles peuvent regrouper une opération scalaire, une opération vectorielle et un branchement. Il s'agit d'instructions qui ressemblent grandement à ce qu'on trouve sur les processeurs VLIW. Un point important est que les cartes graphiques modernes disposent d'instructions à ''co-issue'' en plus des instructions normales. Les instructions à ''co-issue'' sont complémentaire des instructions normales, elles ne les remplacent pas. Les deux peuvent s'utiliser en même temps, dans un même shader. Il a cependant existé des cartes graphiques assez anciennes sur lesquelles toutes les instructions étaient des instructions à ''co-issue'' : certains processeurs de shaders VLIW anciens sont de ce type. Il y a de nombreuses contraintes quant au regroupement des deux opérations. On ne peut pas regrouper n'importe quelle opération avec n'importe quelle autre. L'exemple type de ''co-issue'' est la ''co-issue'' entre opérations scalaires et vectorielles : il n'est pas possible de regrouper deux instructions scalaires ou deux instructions vectorielles. La seule possibilité est de regrouper une opération scalaire et une opération vectorielle. La raison à cela est qu'opérations scalaires et vectorielles sont calculées dans des circuits séparés : le processeur incorpore une unité de calcul scalaire et une unité de calcul SIMD, et peut utiliser les deux en parallèle, en même temps. Mais nous verrons cela dans quelques chapitres. Pour simplifier, cette technique permettait d’exécuter deux opérations arithmétiques en même temps, en parallèle : une opération vectorielle appliquée aux couleurs R, G, et B, et une opération scalaire appliquée à la couleur de transparence. Si cela semble intéressant sur le papier, cela complexifie fortement le processeur de shader, ainsi que la traduction à la volée des shaders en instructions machine. ===Un exemple : le jeu d’instruction du GPU de la Geforce 3=== La première carte graphique commerciale grand public à disposer d'une unité de vertex programmable est la Geforce 3. Celui-ci respectait le format de vertex shader 1.1. L'ensemble des informations à savoir sur cette unité est disponible dans l'article [https://cseweb.ucsd.edu/~ravir/6160-fall04/papers/p149-lindholm.pdf "A user programmable vertex engine"], disponible sur le net. . Le processeur de cette carte était capable de gérer un seul type de données : les nombres flottants de norme IEEE754. Toutes les informations concernant la coordonnée d'une vertice, voire ses différentes couleurs, doivent être encodées en utilisant ces flottants. Les processeurs de vertices de la Geforce 3 disposent de registres registres SIMD qui font 128 bits, soit 4 flottants de 32 bits. Elle contient 16 registres d'entrée, 16 registres de sortie, 32 registres généraux. La mémoire des constantes contient 512 "registres". Le processeur de la Geforce 3 est capable d’exécuter 17 instructions différentes, dont voici les principales : {|class="wikitable" |- !OpCode!!Nom!!Description |- ! colspan="3" | Opérations mémoire |- |MOV||Move||vector -> vector |- |ARL||Address register load||miscellaneous |- ! colspan="3" | Opérations arithmétiques |- |ADD||Add||vector -> vector |- |MUL||Multiply||vector -> vector |- |MAD||Multiply and add||vector -> vector |- |MIN||Minimum||vector -> vector |- |MAX||Maximum||vector -> vector |- |SLT||Set on less than||vector -> vector |- |SGE||Set on greater or equal||vector -> vector |- |LOG||Log base 2||miscellaneous |- |EXP||Exp base 2||miscellaneous |- |RCP||Reciprocal||scalar-> replicated scalar |- |RSQ||Reciprocal square root||scalar-> replicated scalar |- ! colspan="3" | Opérations trigonométriques |- |DP3||3 term dot product||vector-> replicated scalar |- |DP4||4 term dot product||vector-> replicated scalar |- |DST||Distance||vector -> vector |- ! colspan="3" | Opérations d'éclairage géométrique |- |LIT||Phong lighting||Calcule l'éclairage de Gouraud |} L'instruction la plus intéressante est clairement la dernière : elle éclaire un sommet, en utilisant un éclairage de Phong. Les autres instructions permettent d'implémenter un autre algorithme si besoin, mais cette forme d'éclairage est déjà là à la base. Les autres instructions sont surtout des instructions arithmétiques : multiplications, additions, exponentielles, logarithmes, racines carrées, etc. Pour les instructions d'accès à la mémoire, on trouve une instruction MOV qui déplace le contenu d'un registre dans un autre et une instruction de calcul d'adresse, mais aucune instruction d'accès à la mémoire sur le processeur de la Geforce 3. Plus tard, les unités de ''vertex shader'' ont acquis la possibilité de lire des données dans une texture. On remarque que la division est absente. Il faut dire que la contrainte qui veut que toutes ces instructions s’exécutent en un cycle d'horloge pose quelques problèmes avec la division, qui est une opération plutôt lourde en hardware. À la place, on trouve l'instruction RCP, capable de calculer 1/x, avec x un flottant. Cela permet ainsi de simuler une division : pour obtenir Y/X, il suffit de calculer 1/X avec RCP, et de multiplier le résultat par Y. ==La prédication et le SIMT== Les cartes graphiques récentes peuvent effectuer des branchements, mais ceux-ci sont tout sauf performants. Dès qu'un branchement survient, le processeur est obligé de traiter chaque élément du vecteur un par un, au lieu de tous les traiter en même temps en parallèle. Les performances s'en ressentent, ce qui fait que les branchements sont à éviter le plus possible. Pour améliorer la gestion des conditions, les cartes graphiques modernes incorporent des instructions spécialisées qui permettent de remplacer des codes remplis de branchements par des codes plus simples, compatibles avec l'organisation des données en vecteurs. Si on met de côté le support de certaines instructions courantes, comme la valeur absolue, ou le calcul du minimum/maximum, la technique la plus importante est la technique dite de '''prédication'''. L'idée est que quand une instruction effectue un calcul sur un ou deux vecteurs, certains éléments du vecteur sont ignorés. Les éléments à ignorer sont choisis suivant le résultat d'une instruction de comparaison, qui effectue un test : les éléments pour lesquels ce test est respecté sont pris en compte, ceux qui ne passent pas le test sont ignorés. Pour donner un exemple d'utilisation, imaginons que l'on ait un vecteur dans lequel on veut remplacer toutes les valeurs négatives par des 0. Dans ce cas, on utilise : * une instruction de comparaison, qui compare chaque élément du vecteur avec 0 et génère plusieurs bits de résultat ; * suivi d'une instruction à prédicat qui met à zéro les éléments pour lesquels les bits de résultat précédents sont à 1. Elle est implémentée grâce à un registre appelé le '''''Vector Mask Register'''''. Celui-ci permet de stocker des informations qui permettront de sélectionner certaines données et pas d'autres pour faire notre calcul. Il est mis à jour par des instructions de comparaison. le ''Vector Mask Register'' stocke un bit pour chaque flottant présent dans le vecteur à traiter, bit qui indique s'il faut appliquer l'instruction sur ce flottant. Si ce bit est à 1, notre instruction doit s’exécuter sur la donnée associée à ce bit. Sinon, notre instruction ne doit pas la modifier. On peut ainsi traiter seulement une partie des registres stockant des vecteurs SIMD. [[File:Vector mask register.png|centre|vignette|upright=2.0|''Vector mask register'']] ===La prédication avec une pile SIMT=== Au niveau du jeu d’instruction, les architectures SIMT implémentent de la prédication, sous une forme améliorée. Les processeurs SIMT actuels sont surtout utilisées sur les processeurs intégrés aux cartes graphiques. Et ces derniers gèrent très mal les branchements, et encore : beaucoup de cartes graphiques, même récentes, ne gèrent tout simplement pas les branchements. Elles doivent donc se débrouiller avec uniquement la prédication, là où les processeurs SIMD utilisent des branchements normaux en complément de la prédication. Insistons sur le fait que cet usage exclusif de la prédication n'est présent que sur une sous-partie des architectures SIMT, le seul exemple que l'auteur de ce wikilivre connait étant celui des cartes graphiques. Les architectures SIMT sans branchements doivent donc trouver des solutions pour gérer les structures de contrôle imbriquées, à savoir une boucle placée à l'intérieur d'une autre boucle, un IF...ELSE dans un autre IF...ELSE, etc. Elles utilisent pour cela la prédication, combinée avec des mécanismes annexes. Le premier d'entre eux est l'usage de plusieurs registres de masques organisés d'une manière bien précise, l'autre est l'usage de compteurs d'activité. Voyons ces deux techniques. La '''pile de masques''' remplace le ou les registres de masque. Sans elle, le processeur SIMD incorpore un registre de masque qui est adressé implicitement ou explicitement. Éventuellement, le processeur peut contenir plusieurs registres de masque séparés adressables via un nom de registre. Avec elle, le processeur SIMD incorpore plusieurs registres de masque organisé en pile. Le registre de masque est donc remplacé par une mémoire LIFO, une pile, dans laquelle plusieurs masques sont empilés. Le tout forme une pile, similaire à la pile d'appel, sauf qu'elle est utilisée pour empiler des masques. Un masque est calculé et empilé à chaque entrée dans une structure de contrôle, puis dépilé une fois la structure de contrôle exécutée. L'empilement et le dépilement des masques est effectué par des instructions PUSH et POP, présentes dans le jeu d'instruction du processeur SIMD. Le calcul des masques doit répondre à plusieurs impératifs. * Premièrement, chaque masque se calcule en faisant un ET entre le masque précédent et le masque calculé par l'instruction de test. Cela permet de ne pas réveiller d’élément au beau milieu d'une structure imbriquée. Si in IF désactive certains éléments du vecteur, une condition imbriquée dans ce IF ne doit pas réveiller cet élément. Le fait de faire un ET entre les masques garantit cela. * Deuxièmement, les masques doivent être empilés et dépilés correctement. Au moment de rentrer dans une structure de contrôle, on effectue une instruction de test associée à la structure de contrôle, qui calcule un masque, et on empile le masque calculé. Au moment de sortir de la structure de contrôle, on dépile le masque en question. L'implémentation demande d'utiliser une mémoire LIFO pour stocker la pile de masques, et quelques circuits annexes. Il faut notamment un circuit relié à l'ALU qui récupère les conditions, les résultats des comparaisons, et qui effectue le ET pour combiner les masques. Pour donner un exemple, prenons le code suivant, qui est volontairement simpliste et ne sert qu'à des fins d'explication : <syntaxhighlight lang="c"> if ( condition 1 ) { if ( condition 2 ) { ... } else { ... } Autres instructions } Instructions après le IF... </syntaxhighlight> Imaginons que l'on traite des vecteurs de 8 éléments. Pour le vecteur considéré, la première condition (a > 0) n'est respectée que par les 4 premiers éléments. L'instruction de condition calcule alors le masque correspondant : 1111 0000. Le masque est alors calculé, puis empilé au sommet de la pile. La seconde instruction de test, qui teste la variable b, est maintenant valide pour les 4 bits du milieu du masque. Mais n'allez pas croire que le masque correspondant soit 0011 11100 : il faut tenir compte de la condition précédente, qui a éliminé les 4 derniers éléments. Pour cela, on fait un ET logique entre le masque précédent, et le masque calculé par la condition. Le masque au sommet de la pile est donc lu, combiné avec le masque calculé par l'instruction, ce qui donne le masque final. Le masque final est alors empilé au sommet de la pile. On exécute alors l'instruction du IF, en tenant compte du masque qui est au sommet de la pile. Si le IF était plus compliqué, toutes les instructions suivantes tiendraient compte du masque. En fait, le masque est pris en compte tant qu'il n'est pas dépilé. Une fois que le IF est terminé, le masque est dépilé. On passe alors au ELSE, et rebelotte. Le masque pour le ELSE est calculé en combinant le masque au sommet de la pile avec la condition du ELSE. Le masque au sommet de la pile est celui calculé à l'entrée du premier IF, pas le second qui a été dépilé. Les instructions du ELSE sont alors exécutées en tenant compte de ce masque. Une fois qu'elles sont toutes exécutées, le masque est dépilé. Puis vient l'exécution des instructions après le ELSE. Elles utilisent le masque empilé au sommet de la pile, qui correspond à celui à l'entrée du IF. Puis vient le moment d'exécuter les instructions après le IF : pas de masque, on exécute sur tout le vecteur. ===Les compteurs d'activité=== Une variante de la technique précédente remplace la pile de masques par des '''compteurs d'activité'''. La technique est similaire, si ce n'est qu'elle utilise moins de circuits. Avant , on avait une pile de masques de même taille, dont les bits sont à 0 ou 1 suivant que la condition est remplie. La pile de masque ressemble donc à ceci : {|class="wikitable" |- ! masque 1 | 1 || 1 || 1 || 1 |- ! masque 2 | 0 || 1 || 1 || 1 |- ! masque 3 | 0 || 1 || 1 || 1 |- ! masque 4 | 0 || 0 || 0 || 1 |- ! masque 1 | colspan="4" | vide |} Une manière équivalente de représenter cette pile de masque est de compter combien de bits sont à 0 dans chaque colonne. Attention : j'ai bien dit à 0 ! On obtient alors : {|class="wikitable" |- ! masque 1 | 3 || 1 || 1 || 0 |} Et c'est le principe caché derrière la technique des compteurs d'activité. Chaque élément dans un vecteur, chaque place, se voit attribuer un compteur. Un compteur non-nul indique qu'il ne faut pas prendre en compte l’élément. Ce n'est qu'une fois que le compteur est nul que l'on effectue des opérations sur l’élément associé du vecteur. À chaque fois qu'on entre dans une structure de contrôle, on teste une condition sur chaque élément. Si la condition est respectée pour un élément, alors le compteur ne change pas. Mais si la condition n'est pas respectée, alors on incrémente le compteur associé. En sortant de la structure de contrôle, on décrémente le compteur associé. Notons que les compteurs qui n'ont pas été incrémentés en entrant dans la structure de contrôle ne sont pas décrémentés en sortant. En clair, là où on empilait/dépilait un masque, on se contente d'incrémenter/décrémenter un compteur. Utiliser un compteur en lieu et place d'une colonne entière dans la pile de masque utilise moins de bits. Et c'est sans doute pour cette raison que certaines cartes graphiques, comme les cartes graphiques intégrées d'Intel depuis 2004, utilisent cette technique. <noinclude> {{NavChapitre | book=Les cartes graphiques | prev=L'évolution vers la programmabilité : les GPUs | prevText=L'évolution vers la programmabilité : les GPUs | next=La microarchitecture des processeurs de shaders | nextText=La microarchitecture des processeurs de shaders }}{{autocat}} </noinclude> gde5e23yit7xgakkp35wbqouv4qpq4c 765153 765152 2026-04-26T20:15:42Z Mewtow 31375 /* Les instructions SIMD */ 765153 wikitext text/x-wiki Les '''''shaders''''' sont des programmes informatiques exécutés par la carte graphique, et plus précisément par des processeurs de ''shaders''. Un point très important à comprendre est que chaque triangle ou pixel d'une scène 3D peut être traité indépendamment des autres. Le tout se résume comme suit : : '''L’exécution d'un shader génère un grand nombre d'instances de ce shader, chacune traitant un paquet de pixels/sommets différent.''' En conséquence, il est possible de traiter chaque instance d'un ''shader'' en parallèle des autres, en même temps, au lieu de traiter les instances l'une après l'autre. La conséquence est que les cartes graphiques sont des architectures massivement parallèles, à savoir qu'elles sont capables d'effectuer un grand nombre de calculs indépendants en même temps. De plus, le parallélisme utilisé est du parallélisme de données, à savoir qu'on exécute le même programme sur des données différentes, chaque donnée étant traitée en parallèle des autres. Les cartes graphiques récentes incorporent toutes les techniques de parallélisme de donnée au niveau matériel, et nous allons toutes les détailler dans ce chapitre. S'il fallait résumer, elles ont plusieurs processeurs/cœurs, chaque cœur est capable d’exécuter des instructions SIMD (ils ne font que cela, à vrai dire), les cœurs sont fortement multithreadés, et j'en passe. Le premier point est qu'une carte graphique contient de nombreux processeurs, qui eux-mêmes contiennent plusieurs unités de calcul. Savoir combien de cœurs contient une carte graphique est cependant très compliqué, car la terminologie utilisée par les fabricants de carte graphique est particulièrement confuse. Il n'est pas rare que ceux-ci appellent cœurs ou processeurs, ce qui correspond en réalité à une unité de calcul d'un processeur normal, sans doute histoire de gonfler les chiffres. Et on peut généraliser à la majorité de la terminologie utilisée par les fabricants, que ce soit pour les termes ''warps processor'', ou autre, qui ne sont pas aisés à interpréter. L'architecture d'une carte graphique récente est illustrée ci-dessous. Rien de bien déroutant pour qui a déjà étudié les architectures à parallélisme de données, mais quelques rappels ou explications ne peuvent pas faire de mal. Le premier point est la présence d'un grand nombre de processeurs/cœurs, les rectangles en bleu/rouge. Chacun d'entre eux contient un grand nombre de circuits de calculs, avec des circuits de calcul simples mais nombreux en rouge, et une unité pour les calculs complexes (trigonométriques, racines carrées, autres) en rouge. Le tout est relié à une hiérarchie mémoire indiquée en vert, comprenant des mémoires locales en complément de la mémoire vidéo principale. Le tout est alimenté par une unité de répartition, le '''''Thread Execution Control Unit''''' en jaune, qui répartit les différentes instances du ''shader'' sur les différents processeurs. Elle est aussi appelée le '''processeur de commandes''', comme nous le verrons dans quelques chapitres. Nous utiliserons le terme processeur de commande dans ce qui suit. [[File:NVIDIA GPU Accelerator Block Diagram.png|centre|vignette|upright=2.5|Ce schéma illustre l'architecture d'un GPU en utilisant la terminologie NVIDIA. Comme on le voit, la carte graphique contient plusieurs cœurs de processeur distincts. Chacun d'entre eux contient plusieurs unités de calcul généralistes, appelées processeurs de threads, qui s'occupent de calculs simples (en bleu). D'autres calculs plus complexes sont pris en charge par une unité de calcul spécialisée (en rouge). Ces cœurs sont alimentés en instructions par le processeur de commandes, ici appelé ''Thread Execution Control Unit'', qui répartit les différents shaders sur chaque cœur. Enfin, on voit que chaque cœur a accès à une mémoire locale dédiée, en plus d'une mémoire vidéo partagée entre tous les cœurs.]] Les portions bleu, jaune et verte du schéma précédent méritent chacune un chapitre séparé. La hiérarchie mémoire en vert fera l'objet d'un chapitre ultérieur. Quant au répartiteur en jaune, il sera détaillé en profondeur dans le prochain chapitre. Dans ce chapitre, nous allons voir comment fonctionnent les processeurs de ''shaders'', la partie bleue. Nous allons voir que ceux-ci ne sont pas très différents des processeurs que l'on trouve dans les ordinateurs normaux, du moins dans les grandes lignes. Ce sont des processeurs séquentiels, qui exécutent des instructions les unes après les autres. Ils ont des instructions machines, des modes d'adressage, un assembleur, des registres et tout ce qui fait qu'un processeur est un processeur. Néanmoins, il y a une différence de taille : ce sont des processeurs adaptés pour effectuer un grand nombre de calculs en parallèle. ==Les registres des processeurs de shaders== Un processeur de shaders contient beaucoup de '''registres généraux''', qui servent un peu à tout. Le programmeur de shader peut les utiliser à loisir. Tout processeur digne de ce nom possède des registres généraux, mais un processeur de ''shader'' dispose aussi de registres spécialisés, qu'on ne trouve que sur les processeurs de ''shaders''. Ils servent à l'interfacer avec le reste du pipeline graphique. Par exemple, des registres pour les textures, d'autres pour recevoir des données du rastériseur, etc. [[File:Architecture carte graphique vertex avec texture.PNG|centre|vignette|upright=2|Architecture carte graphique vertex avec texture]] ===Les registres d'interface avec le pipeline graphique=== Les processeurs de ''vertex shader'' reçoivent des sommets provenant de l'''input assembler'' et envoient leur résultat au rastériseur. Les processeurs de ''pixel shader'' reçoivent des données de l'unité de rastérisation, et envoient un pixel éclairé aux ROPs. Et pour cela, le processeur de shader a des registres dédiés, qui servent d'interface avec le reste du pipeline graphique. Les '''registres de sortie''' sont là où le processeur stocke les résultats à envoyer, soit au rastériseur pour un ''vertex shader'', soit aux ROP pour un ''pixel shader''. Les registres de sorties sont en écriture seule. Pour donner un exemple, les ''vertex shaders'' ont au minimum un registre pour la position du sommet dans l'espace (trois coordonnées), un autre pour la couleur/luminosité du sommet, un autre pour la couleur du brouillard, un autre pour les coordonnées de texture. {|class="wikitable" |+ Registres de sortie des ''pixel/vertex shaders'' |- ! Vertex shader ! Pixel shader |- | Couleur du pixel | Couleur du sommet |- | Profondeur du pixel | Position du sommet |- | rowspan="2" | | Coordonnées de texture du sommet |- | Couleur de brouillard. |} Les '''registres d'entrée''', aussi appelés '''registres d'attributs''', réceptionnent soit les sommets provenant de l'''input assembler'', soit les pixels provenant de l'unité de rastérisation. Les registres d'entrée sont en lecture seule, du point de vue du processeur de shader, ils sont initialisés avant l'exécution de l'instance du ''shader''. Les '''registres de constantes''' mémorisent des constantes utiles pour le ''shader''. Par exemple, pour les ''vertex shaders'', ils stockent les matrices servant aux différentes étapes de transformation ou d'éclairage. La différence avec les registres d'attribut est qu'ils mémorisent des données constantes pour un objet/modèle 3D (pour un ''draw call'', pour être plus précis) : des matrices de transformation, les adresses de texture, et bien d'autres. A l'opposé, les registres d'attributs mémorisent des sommets/pixels qui varient d'une instance de shader à l'autre. Les ''pixel/vertex shaders'' 1.0 ne géraient que des constantes flottantes pour les ''vertex shaders'', entières pour les ''pixel shaders''. Les ''pixel/vertex shaders'' 2.0 et 3.0 ont ajouté des registres de constantes pour les nombres entiers et des opérandes booléennes. Il y en avait 16, comparé aux centaines de registres de constantes flottants. Les constantes entières et booléennes étaient utilisées pour gérer les boucles, guère plus. Avec les ''pixel/vertex shaders'' 4.0 et plus, les registres de constante n'ont plus de type prédéterminé, le programmeur gère ces registres comme il l'entend. L'adressage des registres de constante est quelque peu particulier. Il faut dire qu'il y en a plusieurs milliers sur les processeurs de ''shaders'' modernes, au point qu'il serait plus juste de parler de mémoire RAM des constantes. Les registres de constante sont en effet un ''local store'' un peu spécial, intégré directement dans le processeur. Et le processeur accède à ce ''local store'' en utilisant une mode d'adressage semblable à celui utilisé pour la mémoire, avec un mode d'adressage indirect. L'adresse à lire dans ce ''local store'' est dans un registre, séparé du reste, appelé le '''registre d'adresse de constante'''. ===Les registres spécialisés internes=== D'autres registres spécialisés ne font pas l'interface avec le reste du GPU. Ils servent à stocker des constantes ou des données importantes, qui n'ont pas vraiment leur place dans les registres généraux. Depuis les ''pixel/vertex shaders'' 3.0, les ''shaders'' sont capables d'effectuer des boucles et d'autres structures de contrôle familières pour les programmeurs. Et deux registres ont été intégrés afin d'améliorer les performances des structures de contrôle. Le premier est un registre à prédicat, qui sera vu dans la section sur le SIMD avec prédication. Le second est un '''registre compteur de boucle''', qui mémorise l'indice d'une boucle. Il est initialisé à 0, et est incrémenté à chaque fois qu'une boucle s'exécute. Certains processeurs de shader ont aussi des '''registres de texture''' , qui servent d'interface avec la mémoire pour la gestion des textures. Ils mémorisent les texels lus par l'unité de texture. L'unité de texture lit un texel, plusieurs avec ''multitexturing'', et les place dans ces registres de texture. Les registres de texture sont parfois initialisés avant l'exécution du ''shader'', mais la plupart sont initialisé quand le ''shader'' termine une instruction de lecture de texture. Ils sont généralement en lecture seule, mais il y a des exceptions. ==Les processeurs de shaders modernes : les processeurs SIMD== Maintenant, voyons quelles sont les instructions supportées par les processeurs de shaders modernes. Et si je dis moderne, c'est car nous ne parlerons que des GPU de l'époque DirectX 10 et après, pas des GPU de l'époque DirectX 9 et antérieur. La raison est que le jeu d'instruction des shaders a franchement évolué, avec le passage d'architectures VLIW à des architectures SIMD. Et cela a eu des conséquences assez profondes sur le jeu d'instruction et leur microarchitecture. Nous n'allons parler des GPU de type SIMD dans ce chapitre. Un chapitre dédié sera consacré aux GPU de type VLIW. Le jeu d'instruction des GPU NVIDIA n'est pas encore connu à l'heure où j'écris ces lignes, la documentation du constructeur n'est pas disponible. Quelques chercheurs ont tenté de faire de la rétro-ingénierie du code de divers shaders pour retrouver le jeu d'instruction des divers GPU NVIDIA, ce qui fait qu'on a cependant une idée de ce dernier. Mais rien d'officiel. Par contre, AMD fournit librement cette documentation sur le net. Ce qui fait qu'on peut trouver des documents de ce genre : * [https://developer.amd.com/wordpress/media/2012/12/AMD_Southern_Islands_Instruction_Set_Architecture.pdf Graphics Core Next 1 instruction set] ; * [https://developer.amd.com/wordpress/media/2013/07/AMD_Sea_Islands_Instruction_Set_Architecture.pdf Graphics Core Next 2 instruction set] ; * [https://developer.amd.com/wordpress/media/2013/12/AMD_GCN3_Instruction_Set_Architecture_rev1.1.pdf Graphics Core Next 3 and 4 instruction sets] ; * [https://developer.amd.com/wp-content/resources/Vega_Shader_ISA_28July2017.pdf Graphics Core Next 5 instruction set] ; * [https://developer.amd.com/wp-content/resources/Vega_7nm_Shader_ISA.pdf "Vega" 7nm instruction set architecture] (also referred to as Graphics Core Next 5.1) ; * [https://www.amd.com/content/dam/amd/en/documents/radeon-tech-docs/instruction-set-architectures/rdna3-shader-instruction-set-architecture-feb-2023_0.pdf Jeu d'instruction des GPU de type RDNA3 d'AMD]. ===Les instructions SIMD=== Les '''instructions SIMD''' manipulent plusieurs nombres en même temps. Elles manipulent plus précisément des '''vecteurs''', des ensembles de plusieurs nombres entiers ou nombres flottants placés les uns à côté des autres, le tout ayant une taille fixe, qui sont stockés dans des registres spécialisés. En général, tous les vecteurs ont une taille fixe, peu importe leur contenu. Cela implique que suivant la taille des données à manipuler, on pourra en placer plus ou moins dans un vecteur. Par exemple, un vecteur de 128 bits pourra contenir 4 entiers de 32 bits, 4 flottants 32 bits, ou 8 entiers de 16 bits. [[File:Vector register.png|centre|vignette|upright=2|Contenu d'un vecteur en fonction du type de données utilisé.]] Les vecteurs sont stockés dans des '''registres vectoriels''', aussi appelés '''registres SIMD'''. Un registre vectoriel peut contenir un vecteur complet, pas plus. En conséquence, ils ont une taille assez importante : ils font généralement 128, 256, voire 512 bits, comparé aux 32/64 bits des registres des CPU. Les cartes graphiques modernes contiennent un très grand nombre de registres SIMD. {| |+ Comparaison entre un processeur sans registres vectoriels, et avec registres vectoriels. |[[File:Non-SIMD cpu diagram1.svg|vignette|upright=1.5|CPU Non-SIMD]] |[[File:SIMD cpu diagram1.svg|vignette|upright=1.5|CPU SIMD]] |} Une instruction SIMD traite chaque donnée du vecteur indépendamment des autres. Par exemple, une instruction d'addition vectorielle va additionner ensemble les données qui sont à la même place dans deux vecteurs, et placer le résultat dans un autre vecteur, à la même place. [[File:Instructions SIMD.png|centre|vignette|upright=2.5|Instructions SIMD]] Sur les cartes graphiques modernes, les vecteurs sont généralement des vecteurs qui regroupent plusieurs nombres flottants. De plus, les flottants en question sont des flottants dits simple précision, codés sur 32 bits. Mais il y a quelques exceptions, comme [https://www.realworldtech.com/apple-custom-gpu/ certains GPU d'Apple, qui ne gèrent majoritairement que des flottants codés sur 16 bits], avec des fonctionnalités pour la simple précision. Les anciennes cartes graphiques ne géraient pas du tout de vecteurs contenant des nombres entiers. ===Les instruction scalaires entières, typiques des CPU=== Un processeur SIMD gère donc des instructions SIMD, et les anciennes cartes graphiques ne disposaient que d'instructions de ce type. Mais depuis au moins une décennie, les processeurs de shaders gèrent des instructions normales, non-SIMD. De telles instructions sont appelées des '''instruction scalaires'''. En clair, il s'agit des instructions qu'on retrouve normalement tous les processeurs principaux (les CPU). Il s'agit généralement d''''instructions entières''', agissent sur des registres entiers non-SIMD. Elles ne traitent pas de vecteur, mais de simples nombres entiers indépendants, sans regroupement d'aucune sorte. Typiquement, il s'agit d'opérations d'addition, de soustraction, des opérations logiques, des comparaisons, guère plus. On trouve aussi des opérations un peu originales, comme des calculs de valeur absolue, du minimum/maximum de deux opérandes, des opérations à prédicat comme une instruction CMOV, etc. Les cartes graphiques supportent rarement la multiplication, mais les plus récentes supportent des multiplications sur des opérandes de 16/32 bits. Par contre, aucune ne gère de division entière. Les GPU modernes gèrent aussi des instructions de test et de branchement, là encore sur des nombres entiers. Les instructions de test et branchement sont généralement considérées comme à part des instructions de calcul, mais ce sont des opérations scalaires. Les comparaisons se font entre deux entiers scalaires, pas entre deux vecteurs. Retenez bien ce détail, car il sera très important pour la suite. Les GPU modernes gèrent aussi des '''instructions flottantes scalaires''', à savoir que des instructions qui ont pour opérandes des nombres flottants isolés, qui ne sont pas dans un vecteur. Les processeurs principaux (CPU) d'un ordinateur sont capables de faire beaucoup de calculs arithmétiques simples sur des nombres flottants, comme des additions, des multiplications, des opérations bit-à-bit, éventuellement des divisions, etc. Il en est de même sur les GPUS. Mais ces derniers gèrent aussi de nombreuses instructions flottantes que les CPU n'incorporent presque pas. Il est rare que les CPU soient capables de faire des opérations flottantes complexes, comme des calculs trigonométriques, des exponentielles, des logarithmes, des racines carrées ou racines carrées inverse, etc. De tels calculs sont rares dans les programmes exécutables, alors que les calculs arithmétiques simples y sont légion. Mais le rendu 3D demande pas mal de calculs trigonométriques, de produits scalaires ou d'autres opérations. Par exemple, dans les chapitres précédents, nous avions abordé les calculs d'éclairage et avions vu qu'ils font beaucoup de calculs vectoriels avec des vecteurs comme la normale d'un sommet. Et ces calculs demandent de calculer des produits scalaires et vectoriels, qui eux-mêmes demandent des calculs trigonométriques comme le cosinus ou le sinus. Aussi, les processeurs de ''shaders'' disposent souvent d'instructions flottantes spécialisées dans les calculs complexes : exponentielle/logarithme, racine carrée, racine carrée inverse, autres. Nous appellerons ces instructions des '''instructions transcendantales''', car elles effectuent des calculs de ce type. Il faut noter que le processeur incorpore des registres dédiés aux scalaires, séparés des registres SIMD. Par séparés, on veut dire que ce sont des registres différents, adressés différemment, mais qu'ils sont aussi physiquement séparés dans le processeur, ils sont des bancs de registres différents. ===Les instructions en ''co-issue''=== Beaucoup de cartes graphiques récentes comme anciennes incorporent des '''instructions de ''co-issue''''' qui ne se trouvent que sur les cartes graphiques et n'ont aucun équivalent sur les CPUs. Les instructions de ''co-issue'' regroupent plusieurs opérations par instruction. Par exemple, elles peuvent combiner une opération vectorielle avec une opération scalaire. Ou encore, elles peuvent regrouper une opération scalaire, une opération vectorielle et un branchement. Il s'agit d'instructions qui ressemblent grandement à ce qu'on trouve sur les processeurs VLIW. Un point important est que les cartes graphiques modernes disposent d'instructions à ''co-issue'' en plus des instructions normales. Les instructions à ''co-issue'' sont complémentaire des instructions normales, elles ne les remplacent pas. Les deux peuvent s'utiliser en même temps, dans un même shader. Il a cependant existé des cartes graphiques assez anciennes sur lesquelles toutes les instructions étaient des instructions à ''co-issue'' : certains processeurs de shaders VLIW anciens sont de ce type. Il y a de nombreuses contraintes quant au regroupement des deux opérations. On ne peut pas regrouper n'importe quelle opération avec n'importe quelle autre. L'exemple type de ''co-issue'' est la ''co-issue'' entre opérations scalaires et vectorielles : il n'est pas possible de regrouper deux instructions scalaires ou deux instructions vectorielles. La seule possibilité est de regrouper une opération scalaire et une opération vectorielle. La raison à cela est qu'opérations scalaires et vectorielles sont calculées dans des circuits séparés : le processeur incorpore une unité de calcul scalaire et une unité de calcul SIMD, et peut utiliser les deux en parallèle, en même temps. Mais nous verrons cela dans quelques chapitres. Pour simplifier, cette technique permettait d’exécuter deux opérations arithmétiques en même temps, en parallèle : une opération vectorielle appliquée aux couleurs R, G, et B, et une opération scalaire appliquée à la couleur de transparence. Si cela semble intéressant sur le papier, cela complexifie fortement le processeur de shader, ainsi que la traduction à la volée des shaders en instructions machine. ===Un exemple : le jeu d’instruction du GPU de la Geforce 3=== La première carte graphique commerciale grand public à disposer d'une unité de vertex programmable est la Geforce 3. Celui-ci respectait le format de vertex shader 1.1. L'ensemble des informations à savoir sur cette unité est disponible dans l'article [https://cseweb.ucsd.edu/~ravir/6160-fall04/papers/p149-lindholm.pdf "A user programmable vertex engine"], disponible sur le net. . Le processeur de cette carte était capable de gérer un seul type de données : les nombres flottants de norme IEEE754. Toutes les informations concernant la coordonnée d'une vertice, voire ses différentes couleurs, doivent être encodées en utilisant ces flottants. Les processeurs de vertices de la Geforce 3 disposent de registres registres SIMD qui font 128 bits, soit 4 flottants de 32 bits. Elle contient 16 registres d'entrée, 16 registres de sortie, 32 registres généraux. La mémoire des constantes contient 512 "registres". Le processeur de la Geforce 3 est capable d’exécuter 17 instructions différentes, dont voici les principales : {|class="wikitable" |- !OpCode!!Nom!!Description |- ! colspan="3" | Opérations mémoire |- |MOV||Move||vector -> vector |- |ARL||Address register load||miscellaneous |- ! colspan="3" | Opérations arithmétiques |- |ADD||Add||vector -> vector |- |MUL||Multiply||vector -> vector |- |MAD||Multiply and add||vector -> vector |- |MIN||Minimum||vector -> vector |- |MAX||Maximum||vector -> vector |- |SLT||Set on less than||vector -> vector |- |SGE||Set on greater or equal||vector -> vector |- |LOG||Log base 2||miscellaneous |- |EXP||Exp base 2||miscellaneous |- |RCP||Reciprocal||scalar-> replicated scalar |- |RSQ||Reciprocal square root||scalar-> replicated scalar |- ! colspan="3" | Opérations trigonométriques |- |DP3||3 term dot product||vector-> replicated scalar |- |DP4||4 term dot product||vector-> replicated scalar |- |DST||Distance||vector -> vector |- ! colspan="3" | Opérations d'éclairage géométrique |- |LIT||Phong lighting||Calcule l'éclairage de Gouraud |} L'instruction la plus intéressante est clairement la dernière : elle éclaire un sommet, en utilisant un éclairage de Phong. Les autres instructions permettent d'implémenter un autre algorithme si besoin, mais cette forme d'éclairage est déjà là à la base. Les autres instructions sont surtout des instructions arithmétiques : multiplications, additions, exponentielles, logarithmes, racines carrées, etc. Pour les instructions d'accès à la mémoire, on trouve une instruction MOV qui déplace le contenu d'un registre dans un autre et une instruction de calcul d'adresse, mais aucune instruction d'accès à la mémoire sur le processeur de la Geforce 3. Plus tard, les unités de ''vertex shader'' ont acquis la possibilité de lire des données dans une texture. On remarque que la division est absente. Il faut dire que la contrainte qui veut que toutes ces instructions s’exécutent en un cycle d'horloge pose quelques problèmes avec la division, qui est une opération plutôt lourde en hardware. À la place, on trouve l'instruction RCP, capable de calculer 1/x, avec x un flottant. Cela permet ainsi de simuler une division : pour obtenir Y/X, il suffit de calculer 1/X avec RCP, et de multiplier le résultat par Y. ==La prédication et le SIMT== Les cartes graphiques récentes peuvent effectuer des branchements, mais ceux-ci sont tout sauf performants. Dès qu'un branchement survient, le processeur est obligé de traiter chaque élément du vecteur un par un, au lieu de tous les traiter en même temps en parallèle. Les performances s'en ressentent, ce qui fait que les branchements sont à éviter le plus possible. Pour améliorer la gestion des conditions, les cartes graphiques modernes incorporent des instructions spécialisées qui permettent de remplacer des codes remplis de branchements par des codes plus simples, compatibles avec l'organisation des données en vecteurs. Si on met de côté le support de certaines instructions courantes, comme la valeur absolue, ou le calcul du minimum/maximum, la technique la plus importante est la technique dite de '''prédication'''. L'idée est que quand une instruction effectue un calcul sur un ou deux vecteurs, certains éléments du vecteur sont ignorés. Les éléments à ignorer sont choisis suivant le résultat d'une instruction de comparaison, qui effectue un test : les éléments pour lesquels ce test est respecté sont pris en compte, ceux qui ne passent pas le test sont ignorés. Pour donner un exemple d'utilisation, imaginons que l'on ait un vecteur dans lequel on veut remplacer toutes les valeurs négatives par des 0. Dans ce cas, on utilise : * une instruction de comparaison, qui compare chaque élément du vecteur avec 0 et génère plusieurs bits de résultat ; * suivi d'une instruction à prédicat qui met à zéro les éléments pour lesquels les bits de résultat précédents sont à 1. Elle est implémentée grâce à un registre appelé le '''''Vector Mask Register'''''. Celui-ci permet de stocker des informations qui permettront de sélectionner certaines données et pas d'autres pour faire notre calcul. Il est mis à jour par des instructions de comparaison. le ''Vector Mask Register'' stocke un bit pour chaque flottant présent dans le vecteur à traiter, bit qui indique s'il faut appliquer l'instruction sur ce flottant. Si ce bit est à 1, notre instruction doit s’exécuter sur la donnée associée à ce bit. Sinon, notre instruction ne doit pas la modifier. On peut ainsi traiter seulement une partie des registres stockant des vecteurs SIMD. [[File:Vector mask register.png|centre|vignette|upright=2.0|''Vector mask register'']] ===La prédication avec une pile SIMT=== Au niveau du jeu d’instruction, les architectures SIMT implémentent de la prédication, sous une forme améliorée. Les processeurs SIMT actuels sont surtout utilisées sur les processeurs intégrés aux cartes graphiques. Et ces derniers gèrent très mal les branchements, et encore : beaucoup de cartes graphiques, même récentes, ne gèrent tout simplement pas les branchements. Elles doivent donc se débrouiller avec uniquement la prédication, là où les processeurs SIMD utilisent des branchements normaux en complément de la prédication. Insistons sur le fait que cet usage exclusif de la prédication n'est présent que sur une sous-partie des architectures SIMT, le seul exemple que l'auteur de ce wikilivre connait étant celui des cartes graphiques. Les architectures SIMT sans branchements doivent donc trouver des solutions pour gérer les structures de contrôle imbriquées, à savoir une boucle placée à l'intérieur d'une autre boucle, un IF...ELSE dans un autre IF...ELSE, etc. Elles utilisent pour cela la prédication, combinée avec des mécanismes annexes. Le premier d'entre eux est l'usage de plusieurs registres de masques organisés d'une manière bien précise, l'autre est l'usage de compteurs d'activité. Voyons ces deux techniques. La '''pile de masques''' remplace le ou les registres de masque. Sans elle, le processeur SIMD incorpore un registre de masque qui est adressé implicitement ou explicitement. Éventuellement, le processeur peut contenir plusieurs registres de masque séparés adressables via un nom de registre. Avec elle, le processeur SIMD incorpore plusieurs registres de masque organisé en pile. Le registre de masque est donc remplacé par une mémoire LIFO, une pile, dans laquelle plusieurs masques sont empilés. Le tout forme une pile, similaire à la pile d'appel, sauf qu'elle est utilisée pour empiler des masques. Un masque est calculé et empilé à chaque entrée dans une structure de contrôle, puis dépilé une fois la structure de contrôle exécutée. L'empilement et le dépilement des masques est effectué par des instructions PUSH et POP, présentes dans le jeu d'instruction du processeur SIMD. Le calcul des masques doit répondre à plusieurs impératifs. * Premièrement, chaque masque se calcule en faisant un ET entre le masque précédent et le masque calculé par l'instruction de test. Cela permet de ne pas réveiller d’élément au beau milieu d'une structure imbriquée. Si in IF désactive certains éléments du vecteur, une condition imbriquée dans ce IF ne doit pas réveiller cet élément. Le fait de faire un ET entre les masques garantit cela. * Deuxièmement, les masques doivent être empilés et dépilés correctement. Au moment de rentrer dans une structure de contrôle, on effectue une instruction de test associée à la structure de contrôle, qui calcule un masque, et on empile le masque calculé. Au moment de sortir de la structure de contrôle, on dépile le masque en question. L'implémentation demande d'utiliser une mémoire LIFO pour stocker la pile de masques, et quelques circuits annexes. Il faut notamment un circuit relié à l'ALU qui récupère les conditions, les résultats des comparaisons, et qui effectue le ET pour combiner les masques. Pour donner un exemple, prenons le code suivant, qui est volontairement simpliste et ne sert qu'à des fins d'explication : <syntaxhighlight lang="c"> if ( condition 1 ) { if ( condition 2 ) { ... } else { ... } Autres instructions } Instructions après le IF... </syntaxhighlight> Imaginons que l'on traite des vecteurs de 8 éléments. Pour le vecteur considéré, la première condition (a > 0) n'est respectée que par les 4 premiers éléments. L'instruction de condition calcule alors le masque correspondant : 1111 0000. Le masque est alors calculé, puis empilé au sommet de la pile. La seconde instruction de test, qui teste la variable b, est maintenant valide pour les 4 bits du milieu du masque. Mais n'allez pas croire que le masque correspondant soit 0011 11100 : il faut tenir compte de la condition précédente, qui a éliminé les 4 derniers éléments. Pour cela, on fait un ET logique entre le masque précédent, et le masque calculé par la condition. Le masque au sommet de la pile est donc lu, combiné avec le masque calculé par l'instruction, ce qui donne le masque final. Le masque final est alors empilé au sommet de la pile. On exécute alors l'instruction du IF, en tenant compte du masque qui est au sommet de la pile. Si le IF était plus compliqué, toutes les instructions suivantes tiendraient compte du masque. En fait, le masque est pris en compte tant qu'il n'est pas dépilé. Une fois que le IF est terminé, le masque est dépilé. On passe alors au ELSE, et rebelotte. Le masque pour le ELSE est calculé en combinant le masque au sommet de la pile avec la condition du ELSE. Le masque au sommet de la pile est celui calculé à l'entrée du premier IF, pas le second qui a été dépilé. Les instructions du ELSE sont alors exécutées en tenant compte de ce masque. Une fois qu'elles sont toutes exécutées, le masque est dépilé. Puis vient l'exécution des instructions après le ELSE. Elles utilisent le masque empilé au sommet de la pile, qui correspond à celui à l'entrée du IF. Puis vient le moment d'exécuter les instructions après le IF : pas de masque, on exécute sur tout le vecteur. ===Les compteurs d'activité=== Une variante de la technique précédente remplace la pile de masques par des '''compteurs d'activité'''. La technique est similaire, si ce n'est qu'elle utilise moins de circuits. Avant , on avait une pile de masques de même taille, dont les bits sont à 0 ou 1 suivant que la condition est remplie. La pile de masque ressemble donc à ceci : {|class="wikitable" |- ! masque 1 | 1 || 1 || 1 || 1 |- ! masque 2 | 0 || 1 || 1 || 1 |- ! masque 3 | 0 || 1 || 1 || 1 |- ! masque 4 | 0 || 0 || 0 || 1 |- ! masque 1 | colspan="4" | vide |} Une manière équivalente de représenter cette pile de masque est de compter combien de bits sont à 0 dans chaque colonne. Attention : j'ai bien dit à 0 ! On obtient alors : {|class="wikitable" |- ! masque 1 | 3 || 1 || 1 || 0 |} Et c'est le principe caché derrière la technique des compteurs d'activité. Chaque élément dans un vecteur, chaque place, se voit attribuer un compteur. Un compteur non-nul indique qu'il ne faut pas prendre en compte l’élément. Ce n'est qu'une fois que le compteur est nul que l'on effectue des opérations sur l’élément associé du vecteur. À chaque fois qu'on entre dans une structure de contrôle, on teste une condition sur chaque élément. Si la condition est respectée pour un élément, alors le compteur ne change pas. Mais si la condition n'est pas respectée, alors on incrémente le compteur associé. En sortant de la structure de contrôle, on décrémente le compteur associé. Notons que les compteurs qui n'ont pas été incrémentés en entrant dans la structure de contrôle ne sont pas décrémentés en sortant. En clair, là où on empilait/dépilait un masque, on se contente d'incrémenter/décrémenter un compteur. Utiliser un compteur en lieu et place d'une colonne entière dans la pile de masque utilise moins de bits. Et c'est sans doute pour cette raison que certaines cartes graphiques, comme les cartes graphiques intégrées d'Intel depuis 2004, utilisent cette technique. <noinclude> {{NavChapitre | book=Les cartes graphiques | prev=L'évolution vers la programmabilité : les GPUs | prevText=L'évolution vers la programmabilité : les GPUs | next=La microarchitecture des processeurs de shaders | nextText=La microarchitecture des processeurs de shaders }}{{autocat}} </noinclude> 71gv0m0as5g4um8ohpjv1ts37hm0int 765154 765153 2026-04-26T20:16:00Z Mewtow 31375 /* La prédication et le SIMT */ 765154 wikitext text/x-wiki Les '''''shaders''''' sont des programmes informatiques exécutés par la carte graphique, et plus précisément par des processeurs de ''shaders''. Un point très important à comprendre est que chaque triangle ou pixel d'une scène 3D peut être traité indépendamment des autres. Le tout se résume comme suit : : '''L’exécution d'un shader génère un grand nombre d'instances de ce shader, chacune traitant un paquet de pixels/sommets différent.''' En conséquence, il est possible de traiter chaque instance d'un ''shader'' en parallèle des autres, en même temps, au lieu de traiter les instances l'une après l'autre. La conséquence est que les cartes graphiques sont des architectures massivement parallèles, à savoir qu'elles sont capables d'effectuer un grand nombre de calculs indépendants en même temps. De plus, le parallélisme utilisé est du parallélisme de données, à savoir qu'on exécute le même programme sur des données différentes, chaque donnée étant traitée en parallèle des autres. Les cartes graphiques récentes incorporent toutes les techniques de parallélisme de donnée au niveau matériel, et nous allons toutes les détailler dans ce chapitre. S'il fallait résumer, elles ont plusieurs processeurs/cœurs, chaque cœur est capable d’exécuter des instructions SIMD (ils ne font que cela, à vrai dire), les cœurs sont fortement multithreadés, et j'en passe. Le premier point est qu'une carte graphique contient de nombreux processeurs, qui eux-mêmes contiennent plusieurs unités de calcul. Savoir combien de cœurs contient une carte graphique est cependant très compliqué, car la terminologie utilisée par les fabricants de carte graphique est particulièrement confuse. Il n'est pas rare que ceux-ci appellent cœurs ou processeurs, ce qui correspond en réalité à une unité de calcul d'un processeur normal, sans doute histoire de gonfler les chiffres. Et on peut généraliser à la majorité de la terminologie utilisée par les fabricants, que ce soit pour les termes ''warps processor'', ou autre, qui ne sont pas aisés à interpréter. L'architecture d'une carte graphique récente est illustrée ci-dessous. Rien de bien déroutant pour qui a déjà étudié les architectures à parallélisme de données, mais quelques rappels ou explications ne peuvent pas faire de mal. Le premier point est la présence d'un grand nombre de processeurs/cœurs, les rectangles en bleu/rouge. Chacun d'entre eux contient un grand nombre de circuits de calculs, avec des circuits de calcul simples mais nombreux en rouge, et une unité pour les calculs complexes (trigonométriques, racines carrées, autres) en rouge. Le tout est relié à une hiérarchie mémoire indiquée en vert, comprenant des mémoires locales en complément de la mémoire vidéo principale. Le tout est alimenté par une unité de répartition, le '''''Thread Execution Control Unit''''' en jaune, qui répartit les différentes instances du ''shader'' sur les différents processeurs. Elle est aussi appelée le '''processeur de commandes''', comme nous le verrons dans quelques chapitres. Nous utiliserons le terme processeur de commande dans ce qui suit. [[File:NVIDIA GPU Accelerator Block Diagram.png|centre|vignette|upright=2.5|Ce schéma illustre l'architecture d'un GPU en utilisant la terminologie NVIDIA. Comme on le voit, la carte graphique contient plusieurs cœurs de processeur distincts. Chacun d'entre eux contient plusieurs unités de calcul généralistes, appelées processeurs de threads, qui s'occupent de calculs simples (en bleu). D'autres calculs plus complexes sont pris en charge par une unité de calcul spécialisée (en rouge). Ces cœurs sont alimentés en instructions par le processeur de commandes, ici appelé ''Thread Execution Control Unit'', qui répartit les différents shaders sur chaque cœur. Enfin, on voit que chaque cœur a accès à une mémoire locale dédiée, en plus d'une mémoire vidéo partagée entre tous les cœurs.]] Les portions bleu, jaune et verte du schéma précédent méritent chacune un chapitre séparé. La hiérarchie mémoire en vert fera l'objet d'un chapitre ultérieur. Quant au répartiteur en jaune, il sera détaillé en profondeur dans le prochain chapitre. Dans ce chapitre, nous allons voir comment fonctionnent les processeurs de ''shaders'', la partie bleue. Nous allons voir que ceux-ci ne sont pas très différents des processeurs que l'on trouve dans les ordinateurs normaux, du moins dans les grandes lignes. Ce sont des processeurs séquentiels, qui exécutent des instructions les unes après les autres. Ils ont des instructions machines, des modes d'adressage, un assembleur, des registres et tout ce qui fait qu'un processeur est un processeur. Néanmoins, il y a une différence de taille : ce sont des processeurs adaptés pour effectuer un grand nombre de calculs en parallèle. ==Les registres des processeurs de shaders== Un processeur de shaders contient beaucoup de '''registres généraux''', qui servent un peu à tout. Le programmeur de shader peut les utiliser à loisir. Tout processeur digne de ce nom possède des registres généraux, mais un processeur de ''shader'' dispose aussi de registres spécialisés, qu'on ne trouve que sur les processeurs de ''shaders''. Ils servent à l'interfacer avec le reste du pipeline graphique. Par exemple, des registres pour les textures, d'autres pour recevoir des données du rastériseur, etc. [[File:Architecture carte graphique vertex avec texture.PNG|centre|vignette|upright=2|Architecture carte graphique vertex avec texture]] ===Les registres d'interface avec le pipeline graphique=== Les processeurs de ''vertex shader'' reçoivent des sommets provenant de l'''input assembler'' et envoient leur résultat au rastériseur. Les processeurs de ''pixel shader'' reçoivent des données de l'unité de rastérisation, et envoient un pixel éclairé aux ROPs. Et pour cela, le processeur de shader a des registres dédiés, qui servent d'interface avec le reste du pipeline graphique. Les '''registres de sortie''' sont là où le processeur stocke les résultats à envoyer, soit au rastériseur pour un ''vertex shader'', soit aux ROP pour un ''pixel shader''. Les registres de sorties sont en écriture seule. Pour donner un exemple, les ''vertex shaders'' ont au minimum un registre pour la position du sommet dans l'espace (trois coordonnées), un autre pour la couleur/luminosité du sommet, un autre pour la couleur du brouillard, un autre pour les coordonnées de texture. {|class="wikitable" |+ Registres de sortie des ''pixel/vertex shaders'' |- ! Vertex shader ! Pixel shader |- | Couleur du pixel | Couleur du sommet |- | Profondeur du pixel | Position du sommet |- | rowspan="2" | | Coordonnées de texture du sommet |- | Couleur de brouillard. |} Les '''registres d'entrée''', aussi appelés '''registres d'attributs''', réceptionnent soit les sommets provenant de l'''input assembler'', soit les pixels provenant de l'unité de rastérisation. Les registres d'entrée sont en lecture seule, du point de vue du processeur de shader, ils sont initialisés avant l'exécution de l'instance du ''shader''. Les '''registres de constantes''' mémorisent des constantes utiles pour le ''shader''. Par exemple, pour les ''vertex shaders'', ils stockent les matrices servant aux différentes étapes de transformation ou d'éclairage. La différence avec les registres d'attribut est qu'ils mémorisent des données constantes pour un objet/modèle 3D (pour un ''draw call'', pour être plus précis) : des matrices de transformation, les adresses de texture, et bien d'autres. A l'opposé, les registres d'attributs mémorisent des sommets/pixels qui varient d'une instance de shader à l'autre. Les ''pixel/vertex shaders'' 1.0 ne géraient que des constantes flottantes pour les ''vertex shaders'', entières pour les ''pixel shaders''. Les ''pixel/vertex shaders'' 2.0 et 3.0 ont ajouté des registres de constantes pour les nombres entiers et des opérandes booléennes. Il y en avait 16, comparé aux centaines de registres de constantes flottants. Les constantes entières et booléennes étaient utilisées pour gérer les boucles, guère plus. Avec les ''pixel/vertex shaders'' 4.0 et plus, les registres de constante n'ont plus de type prédéterminé, le programmeur gère ces registres comme il l'entend. L'adressage des registres de constante est quelque peu particulier. Il faut dire qu'il y en a plusieurs milliers sur les processeurs de ''shaders'' modernes, au point qu'il serait plus juste de parler de mémoire RAM des constantes. Les registres de constante sont en effet un ''local store'' un peu spécial, intégré directement dans le processeur. Et le processeur accède à ce ''local store'' en utilisant une mode d'adressage semblable à celui utilisé pour la mémoire, avec un mode d'adressage indirect. L'adresse à lire dans ce ''local store'' est dans un registre, séparé du reste, appelé le '''registre d'adresse de constante'''. ===Les registres spécialisés internes=== D'autres registres spécialisés ne font pas l'interface avec le reste du GPU. Ils servent à stocker des constantes ou des données importantes, qui n'ont pas vraiment leur place dans les registres généraux. Depuis les ''pixel/vertex shaders'' 3.0, les ''shaders'' sont capables d'effectuer des boucles et d'autres structures de contrôle familières pour les programmeurs. Et deux registres ont été intégrés afin d'améliorer les performances des structures de contrôle. Le premier est un registre à prédicat, qui sera vu dans la section sur le SIMD avec prédication. Le second est un '''registre compteur de boucle''', qui mémorise l'indice d'une boucle. Il est initialisé à 0, et est incrémenté à chaque fois qu'une boucle s'exécute. Certains processeurs de shader ont aussi des '''registres de texture''' , qui servent d'interface avec la mémoire pour la gestion des textures. Ils mémorisent les texels lus par l'unité de texture. L'unité de texture lit un texel, plusieurs avec ''multitexturing'', et les place dans ces registres de texture. Les registres de texture sont parfois initialisés avant l'exécution du ''shader'', mais la plupart sont initialisé quand le ''shader'' termine une instruction de lecture de texture. Ils sont généralement en lecture seule, mais il y a des exceptions. ==Les processeurs de shaders modernes : les processeurs SIMD== Maintenant, voyons quelles sont les instructions supportées par les processeurs de shaders modernes. Et si je dis moderne, c'est car nous ne parlerons que des GPU de l'époque DirectX 10 et après, pas des GPU de l'époque DirectX 9 et antérieur. La raison est que le jeu d'instruction des shaders a franchement évolué, avec le passage d'architectures VLIW à des architectures SIMD. Et cela a eu des conséquences assez profondes sur le jeu d'instruction et leur microarchitecture. Nous n'allons parler des GPU de type SIMD dans ce chapitre. Un chapitre dédié sera consacré aux GPU de type VLIW. Le jeu d'instruction des GPU NVIDIA n'est pas encore connu à l'heure où j'écris ces lignes, la documentation du constructeur n'est pas disponible. Quelques chercheurs ont tenté de faire de la rétro-ingénierie du code de divers shaders pour retrouver le jeu d'instruction des divers GPU NVIDIA, ce qui fait qu'on a cependant une idée de ce dernier. Mais rien d'officiel. Par contre, AMD fournit librement cette documentation sur le net. Ce qui fait qu'on peut trouver des documents de ce genre : * [https://developer.amd.com/wordpress/media/2012/12/AMD_Southern_Islands_Instruction_Set_Architecture.pdf Graphics Core Next 1 instruction set] ; * [https://developer.amd.com/wordpress/media/2013/07/AMD_Sea_Islands_Instruction_Set_Architecture.pdf Graphics Core Next 2 instruction set] ; * [https://developer.amd.com/wordpress/media/2013/12/AMD_GCN3_Instruction_Set_Architecture_rev1.1.pdf Graphics Core Next 3 and 4 instruction sets] ; * [https://developer.amd.com/wp-content/resources/Vega_Shader_ISA_28July2017.pdf Graphics Core Next 5 instruction set] ; * [https://developer.amd.com/wp-content/resources/Vega_7nm_Shader_ISA.pdf "Vega" 7nm instruction set architecture] (also referred to as Graphics Core Next 5.1) ; * [https://www.amd.com/content/dam/amd/en/documents/radeon-tech-docs/instruction-set-architectures/rdna3-shader-instruction-set-architecture-feb-2023_0.pdf Jeu d'instruction des GPU de type RDNA3 d'AMD]. ===Les instructions SIMD=== Les '''instructions SIMD''' manipulent plusieurs nombres en même temps. Elles manipulent plus précisément des '''vecteurs''', des ensembles de plusieurs nombres entiers ou nombres flottants placés les uns à côté des autres, le tout ayant une taille fixe, qui sont stockés dans des registres spécialisés. En général, tous les vecteurs ont une taille fixe, peu importe leur contenu. Cela implique que suivant la taille des données à manipuler, on pourra en placer plus ou moins dans un vecteur. Par exemple, un vecteur de 128 bits pourra contenir 4 entiers de 32 bits, 4 flottants 32 bits, ou 8 entiers de 16 bits. [[File:Vector register.png|centre|vignette|upright=2|Contenu d'un vecteur en fonction du type de données utilisé.]] Les vecteurs sont stockés dans des '''registres vectoriels''', aussi appelés '''registres SIMD'''. Un registre vectoriel peut contenir un vecteur complet, pas plus. En conséquence, ils ont une taille assez importante : ils font généralement 128, 256, voire 512 bits, comparé aux 32/64 bits des registres des CPU. Les cartes graphiques modernes contiennent un très grand nombre de registres SIMD. {| |+ Comparaison entre un processeur sans registres vectoriels, et avec registres vectoriels. |[[File:Non-SIMD cpu diagram1.svg|vignette|upright=1.5|CPU Non-SIMD]] |[[File:SIMD cpu diagram1.svg|vignette|upright=1.5|CPU SIMD]] |} Une instruction SIMD traite chaque donnée du vecteur indépendamment des autres. Par exemple, une instruction d'addition vectorielle va additionner ensemble les données qui sont à la même place dans deux vecteurs, et placer le résultat dans un autre vecteur, à la même place. [[File:Instructions SIMD.png|centre|vignette|upright=2.5|Instructions SIMD]] Sur les cartes graphiques modernes, les vecteurs sont généralement des vecteurs qui regroupent plusieurs nombres flottants. De plus, les flottants en question sont des flottants dits simple précision, codés sur 32 bits. Mais il y a quelques exceptions, comme [https://www.realworldtech.com/apple-custom-gpu/ certains GPU d'Apple, qui ne gèrent majoritairement que des flottants codés sur 16 bits], avec des fonctionnalités pour la simple précision. Les anciennes cartes graphiques ne géraient pas du tout de vecteurs contenant des nombres entiers. ===Les instruction scalaires entières, typiques des CPU=== Un processeur SIMD gère donc des instructions SIMD, et les anciennes cartes graphiques ne disposaient que d'instructions de ce type. Mais depuis au moins une décennie, les processeurs de shaders gèrent des instructions normales, non-SIMD. De telles instructions sont appelées des '''instruction scalaires'''. En clair, il s'agit des instructions qu'on retrouve normalement tous les processeurs principaux (les CPU). Il s'agit généralement d''''instructions entières''', agissent sur des registres entiers non-SIMD. Elles ne traitent pas de vecteur, mais de simples nombres entiers indépendants, sans regroupement d'aucune sorte. Typiquement, il s'agit d'opérations d'addition, de soustraction, des opérations logiques, des comparaisons, guère plus. On trouve aussi des opérations un peu originales, comme des calculs de valeur absolue, du minimum/maximum de deux opérandes, des opérations à prédicat comme une instruction CMOV, etc. Les cartes graphiques supportent rarement la multiplication, mais les plus récentes supportent des multiplications sur des opérandes de 16/32 bits. Par contre, aucune ne gère de division entière. Les GPU modernes gèrent aussi des instructions de test et de branchement, là encore sur des nombres entiers. Les instructions de test et branchement sont généralement considérées comme à part des instructions de calcul, mais ce sont des opérations scalaires. Les comparaisons se font entre deux entiers scalaires, pas entre deux vecteurs. Retenez bien ce détail, car il sera très important pour la suite. Les GPU modernes gèrent aussi des '''instructions flottantes scalaires''', à savoir que des instructions qui ont pour opérandes des nombres flottants isolés, qui ne sont pas dans un vecteur. Les processeurs principaux (CPU) d'un ordinateur sont capables de faire beaucoup de calculs arithmétiques simples sur des nombres flottants, comme des additions, des multiplications, des opérations bit-à-bit, éventuellement des divisions, etc. Il en est de même sur les GPUS. Mais ces derniers gèrent aussi de nombreuses instructions flottantes que les CPU n'incorporent presque pas. Il est rare que les CPU soient capables de faire des opérations flottantes complexes, comme des calculs trigonométriques, des exponentielles, des logarithmes, des racines carrées ou racines carrées inverse, etc. De tels calculs sont rares dans les programmes exécutables, alors que les calculs arithmétiques simples y sont légion. Mais le rendu 3D demande pas mal de calculs trigonométriques, de produits scalaires ou d'autres opérations. Par exemple, dans les chapitres précédents, nous avions abordé les calculs d'éclairage et avions vu qu'ils font beaucoup de calculs vectoriels avec des vecteurs comme la normale d'un sommet. Et ces calculs demandent de calculer des produits scalaires et vectoriels, qui eux-mêmes demandent des calculs trigonométriques comme le cosinus ou le sinus. Aussi, les processeurs de ''shaders'' disposent souvent d'instructions flottantes spécialisées dans les calculs complexes : exponentielle/logarithme, racine carrée, racine carrée inverse, autres. Nous appellerons ces instructions des '''instructions transcendantales''', car elles effectuent des calculs de ce type. Il faut noter que le processeur incorpore des registres dédiés aux scalaires, séparés des registres SIMD. Par séparés, on veut dire que ce sont des registres différents, adressés différemment, mais qu'ils sont aussi physiquement séparés dans le processeur, ils sont des bancs de registres différents. ===Les instructions en ''co-issue''=== Beaucoup de cartes graphiques récentes comme anciennes incorporent des '''instructions de ''co-issue''''' qui ne se trouvent que sur les cartes graphiques et n'ont aucun équivalent sur les CPUs. Les instructions de ''co-issue'' regroupent plusieurs opérations par instruction. Par exemple, elles peuvent combiner une opération vectorielle avec une opération scalaire. Ou encore, elles peuvent regrouper une opération scalaire, une opération vectorielle et un branchement. Il s'agit d'instructions qui ressemblent grandement à ce qu'on trouve sur les processeurs VLIW. Un point important est que les cartes graphiques modernes disposent d'instructions à ''co-issue'' en plus des instructions normales. Les instructions à ''co-issue'' sont complémentaire des instructions normales, elles ne les remplacent pas. Les deux peuvent s'utiliser en même temps, dans un même shader. Il a cependant existé des cartes graphiques assez anciennes sur lesquelles toutes les instructions étaient des instructions à ''co-issue'' : certains processeurs de shaders VLIW anciens sont de ce type. Il y a de nombreuses contraintes quant au regroupement des deux opérations. On ne peut pas regrouper n'importe quelle opération avec n'importe quelle autre. L'exemple type de ''co-issue'' est la ''co-issue'' entre opérations scalaires et vectorielles : il n'est pas possible de regrouper deux instructions scalaires ou deux instructions vectorielles. La seule possibilité est de regrouper une opération scalaire et une opération vectorielle. La raison à cela est qu'opérations scalaires et vectorielles sont calculées dans des circuits séparés : le processeur incorpore une unité de calcul scalaire et une unité de calcul SIMD, et peut utiliser les deux en parallèle, en même temps. Mais nous verrons cela dans quelques chapitres. Pour simplifier, cette technique permettait d’exécuter deux opérations arithmétiques en même temps, en parallèle : une opération vectorielle appliquée aux couleurs R, G, et B, et une opération scalaire appliquée à la couleur de transparence. Si cela semble intéressant sur le papier, cela complexifie fortement le processeur de shader, ainsi que la traduction à la volée des shaders en instructions machine. ===Un exemple : le jeu d’instruction du GPU de la Geforce 3=== La première carte graphique commerciale grand public à disposer d'une unité de vertex programmable est la Geforce 3. Celui-ci respectait le format de vertex shader 1.1. L'ensemble des informations à savoir sur cette unité est disponible dans l'article [https://cseweb.ucsd.edu/~ravir/6160-fall04/papers/p149-lindholm.pdf "A user programmable vertex engine"], disponible sur le net. . Le processeur de cette carte était capable de gérer un seul type de données : les nombres flottants de norme IEEE754. Toutes les informations concernant la coordonnée d'une vertice, voire ses différentes couleurs, doivent être encodées en utilisant ces flottants. Les processeurs de vertices de la Geforce 3 disposent de registres registres SIMD qui font 128 bits, soit 4 flottants de 32 bits. Elle contient 16 registres d'entrée, 16 registres de sortie, 32 registres généraux. La mémoire des constantes contient 512 "registres". Le processeur de la Geforce 3 est capable d’exécuter 17 instructions différentes, dont voici les principales : {|class="wikitable" |- !OpCode!!Nom!!Description |- ! colspan="3" | Opérations mémoire |- |MOV||Move||vector -> vector |- |ARL||Address register load||miscellaneous |- ! colspan="3" | Opérations arithmétiques |- |ADD||Add||vector -> vector |- |MUL||Multiply||vector -> vector |- |MAD||Multiply and add||vector -> vector |- |MIN||Minimum||vector -> vector |- |MAX||Maximum||vector -> vector |- |SLT||Set on less than||vector -> vector |- |SGE||Set on greater or equal||vector -> vector |- |LOG||Log base 2||miscellaneous |- |EXP||Exp base 2||miscellaneous |- |RCP||Reciprocal||scalar-> replicated scalar |- |RSQ||Reciprocal square root||scalar-> replicated scalar |- ! colspan="3" | Opérations trigonométriques |- |DP3||3 term dot product||vector-> replicated scalar |- |DP4||4 term dot product||vector-> replicated scalar |- |DST||Distance||vector -> vector |- ! colspan="3" | Opérations d'éclairage géométrique |- |LIT||Phong lighting||Calcule l'éclairage de Gouraud |} L'instruction la plus intéressante est clairement la dernière : elle éclaire un sommet, en utilisant un éclairage de Phong. Les autres instructions permettent d'implémenter un autre algorithme si besoin, mais cette forme d'éclairage est déjà là à la base. Les autres instructions sont surtout des instructions arithmétiques : multiplications, additions, exponentielles, logarithmes, racines carrées, etc. Pour les instructions d'accès à la mémoire, on trouve une instruction MOV qui déplace le contenu d'un registre dans un autre et une instruction de calcul d'adresse, mais aucune instruction d'accès à la mémoire sur le processeur de la Geforce 3. Plus tard, les unités de ''vertex shader'' ont acquis la possibilité de lire des données dans une texture. On remarque que la division est absente. Il faut dire que la contrainte qui veut que toutes ces instructions s’exécutent en un cycle d'horloge pose quelques problèmes avec la division, qui est une opération plutôt lourde en hardware. À la place, on trouve l'instruction RCP, capable de calculer 1/x, avec x un flottant. Cela permet ainsi de simuler une division : pour obtenir Y/X, il suffit de calculer 1/X avec RCP, et de multiplier le résultat par Y. ==La prédication et le SIMT== Les cartes graphiques récentes peuvent effectuer des branchements, mais ceux-ci sont tout sauf performants. Dès qu'un branchement survient, le processeur est obligé de traiter chaque élément du vecteur un par un, au lieu de tous les traiter en même temps en parallèle. Les performances s'en ressentent, ce qui fait que les branchements sont à éviter le plus possible. Pour améliorer la gestion des conditions, les cartes graphiques modernes incorporent des instructions spécialisées qui permettent de remplacer des codes remplis de branchements par des codes plus simples, compatibles avec l'organisation des données en vecteurs. Si on met de côté le support de certaines instructions courantes, comme la valeur absolue, ou le calcul du minimum/maximum, la technique la plus importante est la technique dite de '''prédication'''. L'idée est que quand une instruction effectue un calcul sur un ou deux vecteurs, certains éléments du vecteur sont ignorés. Les éléments à ignorer sont choisis suivant le résultat d'une instruction de comparaison, qui effectue un test : les éléments pour lesquels ce test est respecté sont pris en compte, ceux qui ne passent pas le test sont ignorés. Pour donner un exemple d'utilisation, imaginons que l'on ait un vecteur dans lequel on veut remplacer toutes les valeurs négatives par des 0. Dans ce cas, on utilise : * une instruction de comparaison, qui compare chaque élément du vecteur avec 0 et génère plusieurs bits de résultat ; * suivi d'une instruction à prédicat qui met à zéro les éléments pour lesquels les bits de résultat précédents sont à 1. Elle est implémentée grâce à un registre appelé le '''''Vector Mask Register'''''. Celui-ci permet de stocker des informations qui permettront de sélectionner certaines données et pas d'autres pour faire notre calcul. Il est mis à jour par des instructions de comparaison. le ''Vector Mask Register'' stocke un bit pour chaque flottant présent dans le vecteur à traiter, bit qui indique s'il faut appliquer l'instruction sur ce flottant. Si ce bit est à 1, notre instruction doit s’exécuter sur la donnée associée à ce bit. Sinon, notre instruction ne doit pas la modifier. On peut ainsi traiter seulement une partie des registres stockant des vecteurs SIMD. [[File:Vector mask register.png|centre|vignette|upright=2.5|''Vector mask register'']] ===La prédication avec une pile SIMT=== Au niveau du jeu d’instruction, les architectures SIMT implémentent de la prédication, sous une forme améliorée. Les processeurs SIMT actuels sont surtout utilisées sur les processeurs intégrés aux cartes graphiques. Et ces derniers gèrent très mal les branchements, et encore : beaucoup de cartes graphiques, même récentes, ne gèrent tout simplement pas les branchements. Elles doivent donc se débrouiller avec uniquement la prédication, là où les processeurs SIMD utilisent des branchements normaux en complément de la prédication. Insistons sur le fait que cet usage exclusif de la prédication n'est présent que sur une sous-partie des architectures SIMT, le seul exemple que l'auteur de ce wikilivre connait étant celui des cartes graphiques. Les architectures SIMT sans branchements doivent donc trouver des solutions pour gérer les structures de contrôle imbriquées, à savoir une boucle placée à l'intérieur d'une autre boucle, un IF...ELSE dans un autre IF...ELSE, etc. Elles utilisent pour cela la prédication, combinée avec des mécanismes annexes. Le premier d'entre eux est l'usage de plusieurs registres de masques organisés d'une manière bien précise, l'autre est l'usage de compteurs d'activité. Voyons ces deux techniques. La '''pile de masques''' remplace le ou les registres de masque. Sans elle, le processeur SIMD incorpore un registre de masque qui est adressé implicitement ou explicitement. Éventuellement, le processeur peut contenir plusieurs registres de masque séparés adressables via un nom de registre. Avec elle, le processeur SIMD incorpore plusieurs registres de masque organisé en pile. Le registre de masque est donc remplacé par une mémoire LIFO, une pile, dans laquelle plusieurs masques sont empilés. Le tout forme une pile, similaire à la pile d'appel, sauf qu'elle est utilisée pour empiler des masques. Un masque est calculé et empilé à chaque entrée dans une structure de contrôle, puis dépilé une fois la structure de contrôle exécutée. L'empilement et le dépilement des masques est effectué par des instructions PUSH et POP, présentes dans le jeu d'instruction du processeur SIMD. Le calcul des masques doit répondre à plusieurs impératifs. * Premièrement, chaque masque se calcule en faisant un ET entre le masque précédent et le masque calculé par l'instruction de test. Cela permet de ne pas réveiller d’élément au beau milieu d'une structure imbriquée. Si in IF désactive certains éléments du vecteur, une condition imbriquée dans ce IF ne doit pas réveiller cet élément. Le fait de faire un ET entre les masques garantit cela. * Deuxièmement, les masques doivent être empilés et dépilés correctement. Au moment de rentrer dans une structure de contrôle, on effectue une instruction de test associée à la structure de contrôle, qui calcule un masque, et on empile le masque calculé. Au moment de sortir de la structure de contrôle, on dépile le masque en question. L'implémentation demande d'utiliser une mémoire LIFO pour stocker la pile de masques, et quelques circuits annexes. Il faut notamment un circuit relié à l'ALU qui récupère les conditions, les résultats des comparaisons, et qui effectue le ET pour combiner les masques. Pour donner un exemple, prenons le code suivant, qui est volontairement simpliste et ne sert qu'à des fins d'explication : <syntaxhighlight lang="c"> if ( condition 1 ) { if ( condition 2 ) { ... } else { ... } Autres instructions } Instructions après le IF... </syntaxhighlight> Imaginons que l'on traite des vecteurs de 8 éléments. Pour le vecteur considéré, la première condition (a > 0) n'est respectée que par les 4 premiers éléments. L'instruction de condition calcule alors le masque correspondant : 1111 0000. Le masque est alors calculé, puis empilé au sommet de la pile. La seconde instruction de test, qui teste la variable b, est maintenant valide pour les 4 bits du milieu du masque. Mais n'allez pas croire que le masque correspondant soit 0011 11100 : il faut tenir compte de la condition précédente, qui a éliminé les 4 derniers éléments. Pour cela, on fait un ET logique entre le masque précédent, et le masque calculé par la condition. Le masque au sommet de la pile est donc lu, combiné avec le masque calculé par l'instruction, ce qui donne le masque final. Le masque final est alors empilé au sommet de la pile. On exécute alors l'instruction du IF, en tenant compte du masque qui est au sommet de la pile. Si le IF était plus compliqué, toutes les instructions suivantes tiendraient compte du masque. En fait, le masque est pris en compte tant qu'il n'est pas dépilé. Une fois que le IF est terminé, le masque est dépilé. On passe alors au ELSE, et rebelotte. Le masque pour le ELSE est calculé en combinant le masque au sommet de la pile avec la condition du ELSE. Le masque au sommet de la pile est celui calculé à l'entrée du premier IF, pas le second qui a été dépilé. Les instructions du ELSE sont alors exécutées en tenant compte de ce masque. Une fois qu'elles sont toutes exécutées, le masque est dépilé. Puis vient l'exécution des instructions après le ELSE. Elles utilisent le masque empilé au sommet de la pile, qui correspond à celui à l'entrée du IF. Puis vient le moment d'exécuter les instructions après le IF : pas de masque, on exécute sur tout le vecteur. ===Les compteurs d'activité=== Une variante de la technique précédente remplace la pile de masques par des '''compteurs d'activité'''. La technique est similaire, si ce n'est qu'elle utilise moins de circuits. Avant , on avait une pile de masques de même taille, dont les bits sont à 0 ou 1 suivant que la condition est remplie. La pile de masque ressemble donc à ceci : {|class="wikitable" |- ! masque 1 | 1 || 1 || 1 || 1 |- ! masque 2 | 0 || 1 || 1 || 1 |- ! masque 3 | 0 || 1 || 1 || 1 |- ! masque 4 | 0 || 0 || 0 || 1 |- ! masque 1 | colspan="4" | vide |} Une manière équivalente de représenter cette pile de masque est de compter combien de bits sont à 0 dans chaque colonne. Attention : j'ai bien dit à 0 ! On obtient alors : {|class="wikitable" |- ! masque 1 | 3 || 1 || 1 || 0 |} Et c'est le principe caché derrière la technique des compteurs d'activité. Chaque élément dans un vecteur, chaque place, se voit attribuer un compteur. Un compteur non-nul indique qu'il ne faut pas prendre en compte l’élément. Ce n'est qu'une fois que le compteur est nul que l'on effectue des opérations sur l’élément associé du vecteur. À chaque fois qu'on entre dans une structure de contrôle, on teste une condition sur chaque élément. Si la condition est respectée pour un élément, alors le compteur ne change pas. Mais si la condition n'est pas respectée, alors on incrémente le compteur associé. En sortant de la structure de contrôle, on décrémente le compteur associé. Notons que les compteurs qui n'ont pas été incrémentés en entrant dans la structure de contrôle ne sont pas décrémentés en sortant. En clair, là où on empilait/dépilait un masque, on se contente d'incrémenter/décrémenter un compteur. Utiliser un compteur en lieu et place d'une colonne entière dans la pile de masque utilise moins de bits. Et c'est sans doute pour cette raison que certaines cartes graphiques, comme les cartes graphiques intégrées d'Intel depuis 2004, utilisent cette technique. <noinclude> {{NavChapitre | book=Les cartes graphiques | prev=L'évolution vers la programmabilité : les GPUs | prevText=L'évolution vers la programmabilité : les GPUs | next=La microarchitecture des processeurs de shaders | nextText=La microarchitecture des processeurs de shaders }}{{autocat}} </noinclude> t9yrms4jkxdhzngzx5hb4hitq6qxzm8 765167 765154 2026-04-26T20:33:06Z Mewtow 31375 /* Les processeurs de shaders modernes : les processeurs SIMD */ Retrait d'un lien mort + ajout balises noinclude 765167 wikitext text/x-wiki Les '''''shaders''''' sont des programmes informatiques exécutés par la carte graphique, et plus précisément par des processeurs de ''shaders''. Un point très important à comprendre est que chaque triangle ou pixel d'une scène 3D peut être traité indépendamment des autres. Le tout se résume comme suit : : '''L’exécution d'un shader génère un grand nombre d'instances de ce shader, chacune traitant un paquet de pixels/sommets différent.''' En conséquence, il est possible de traiter chaque instance d'un ''shader'' en parallèle des autres, en même temps, au lieu de traiter les instances l'une après l'autre. La conséquence est que les cartes graphiques sont des architectures massivement parallèles, à savoir qu'elles sont capables d'effectuer un grand nombre de calculs indépendants en même temps. De plus, le parallélisme utilisé est du parallélisme de données, à savoir qu'on exécute le même programme sur des données différentes, chaque donnée étant traitée en parallèle des autres. Les cartes graphiques récentes incorporent toutes les techniques de parallélisme de donnée au niveau matériel, et nous allons toutes les détailler dans ce chapitre. S'il fallait résumer, elles ont plusieurs processeurs/cœurs, chaque cœur est capable d’exécuter des instructions SIMD (ils ne font que cela, à vrai dire), les cœurs sont fortement multithreadés, et j'en passe. Le premier point est qu'une carte graphique contient de nombreux processeurs, qui eux-mêmes contiennent plusieurs unités de calcul. Savoir combien de cœurs contient une carte graphique est cependant très compliqué, car la terminologie utilisée par les fabricants de carte graphique est particulièrement confuse. Il n'est pas rare que ceux-ci appellent cœurs ou processeurs, ce qui correspond en réalité à une unité de calcul d'un processeur normal, sans doute histoire de gonfler les chiffres. Et on peut généraliser à la majorité de la terminologie utilisée par les fabricants, que ce soit pour les termes ''warps processor'', ou autre, qui ne sont pas aisés à interpréter. L'architecture d'une carte graphique récente est illustrée ci-dessous. Rien de bien déroutant pour qui a déjà étudié les architectures à parallélisme de données, mais quelques rappels ou explications ne peuvent pas faire de mal. Le premier point est la présence d'un grand nombre de processeurs/cœurs, les rectangles en bleu/rouge. Chacun d'entre eux contient un grand nombre de circuits de calculs, avec des circuits de calcul simples mais nombreux en rouge, et une unité pour les calculs complexes (trigonométriques, racines carrées, autres) en rouge. Le tout est relié à une hiérarchie mémoire indiquée en vert, comprenant des mémoires locales en complément de la mémoire vidéo principale. Le tout est alimenté par une unité de répartition, le '''''Thread Execution Control Unit''''' en jaune, qui répartit les différentes instances du ''shader'' sur les différents processeurs. Elle est aussi appelée le '''processeur de commandes''', comme nous le verrons dans quelques chapitres. Nous utiliserons le terme processeur de commande dans ce qui suit. [[File:NVIDIA GPU Accelerator Block Diagram.png|centre|vignette|upright=2.5|Ce schéma illustre l'architecture d'un GPU en utilisant la terminologie NVIDIA. Comme on le voit, la carte graphique contient plusieurs cœurs de processeur distincts. Chacun d'entre eux contient plusieurs unités de calcul généralistes, appelées processeurs de threads, qui s'occupent de calculs simples (en bleu). D'autres calculs plus complexes sont pris en charge par une unité de calcul spécialisée (en rouge). Ces cœurs sont alimentés en instructions par le processeur de commandes, ici appelé ''Thread Execution Control Unit'', qui répartit les différents shaders sur chaque cœur. Enfin, on voit que chaque cœur a accès à une mémoire locale dédiée, en plus d'une mémoire vidéo partagée entre tous les cœurs.]] Les portions bleu, jaune et verte du schéma précédent méritent chacune un chapitre séparé. La hiérarchie mémoire en vert fera l'objet d'un chapitre ultérieur. Quant au répartiteur en jaune, il sera détaillé en profondeur dans le prochain chapitre. Dans ce chapitre, nous allons voir comment fonctionnent les processeurs de ''shaders'', la partie bleue. Nous allons voir que ceux-ci ne sont pas très différents des processeurs que l'on trouve dans les ordinateurs normaux, du moins dans les grandes lignes. Ce sont des processeurs séquentiels, qui exécutent des instructions les unes après les autres. Ils ont des instructions machines, des modes d'adressage, un assembleur, des registres et tout ce qui fait qu'un processeur est un processeur. Néanmoins, il y a une différence de taille : ce sont des processeurs adaptés pour effectuer un grand nombre de calculs en parallèle. ==Les registres des processeurs de shaders== Un processeur de shaders contient beaucoup de '''registres généraux''', qui servent un peu à tout. Le programmeur de shader peut les utiliser à loisir. Tout processeur digne de ce nom possède des registres généraux, mais un processeur de ''shader'' dispose aussi de registres spécialisés, qu'on ne trouve que sur les processeurs de ''shaders''. Ils servent à l'interfacer avec le reste du pipeline graphique. Par exemple, des registres pour les textures, d'autres pour recevoir des données du rastériseur, etc. [[File:Architecture carte graphique vertex avec texture.PNG|centre|vignette|upright=2|Architecture carte graphique vertex avec texture]] ===Les registres d'interface avec le pipeline graphique=== Les processeurs de ''vertex shader'' reçoivent des sommets provenant de l'''input assembler'' et envoient leur résultat au rastériseur. Les processeurs de ''pixel shader'' reçoivent des données de l'unité de rastérisation, et envoient un pixel éclairé aux ROPs. Et pour cela, le processeur de shader a des registres dédiés, qui servent d'interface avec le reste du pipeline graphique. Les '''registres de sortie''' sont là où le processeur stocke les résultats à envoyer, soit au rastériseur pour un ''vertex shader'', soit aux ROP pour un ''pixel shader''. Les registres de sorties sont en écriture seule. Pour donner un exemple, les ''vertex shaders'' ont au minimum un registre pour la position du sommet dans l'espace (trois coordonnées), un autre pour la couleur/luminosité du sommet, un autre pour la couleur du brouillard, un autre pour les coordonnées de texture. {|class="wikitable" |+ Registres de sortie des ''pixel/vertex shaders'' |- ! Vertex shader ! Pixel shader |- | Couleur du pixel | Couleur du sommet |- | Profondeur du pixel | Position du sommet |- | rowspan="2" | | Coordonnées de texture du sommet |- | Couleur de brouillard. |} Les '''registres d'entrée''', aussi appelés '''registres d'attributs''', réceptionnent soit les sommets provenant de l'''input assembler'', soit les pixels provenant de l'unité de rastérisation. Les registres d'entrée sont en lecture seule, du point de vue du processeur de shader, ils sont initialisés avant l'exécution de l'instance du ''shader''. Les '''registres de constantes''' mémorisent des constantes utiles pour le ''shader''. Par exemple, pour les ''vertex shaders'', ils stockent les matrices servant aux différentes étapes de transformation ou d'éclairage. La différence avec les registres d'attribut est qu'ils mémorisent des données constantes pour un objet/modèle 3D (pour un ''draw call'', pour être plus précis) : des matrices de transformation, les adresses de texture, et bien d'autres. A l'opposé, les registres d'attributs mémorisent des sommets/pixels qui varient d'une instance de shader à l'autre. Les ''pixel/vertex shaders'' 1.0 ne géraient que des constantes flottantes pour les ''vertex shaders'', entières pour les ''pixel shaders''. Les ''pixel/vertex shaders'' 2.0 et 3.0 ont ajouté des registres de constantes pour les nombres entiers et des opérandes booléennes. Il y en avait 16, comparé aux centaines de registres de constantes flottants. Les constantes entières et booléennes étaient utilisées pour gérer les boucles, guère plus. Avec les ''pixel/vertex shaders'' 4.0 et plus, les registres de constante n'ont plus de type prédéterminé, le programmeur gère ces registres comme il l'entend. L'adressage des registres de constante est quelque peu particulier. Il faut dire qu'il y en a plusieurs milliers sur les processeurs de ''shaders'' modernes, au point qu'il serait plus juste de parler de mémoire RAM des constantes. Les registres de constante sont en effet un ''local store'' un peu spécial, intégré directement dans le processeur. Et le processeur accède à ce ''local store'' en utilisant une mode d'adressage semblable à celui utilisé pour la mémoire, avec un mode d'adressage indirect. L'adresse à lire dans ce ''local store'' est dans un registre, séparé du reste, appelé le '''registre d'adresse de constante'''. ===Les registres spécialisés internes=== D'autres registres spécialisés ne font pas l'interface avec le reste du GPU. Ils servent à stocker des constantes ou des données importantes, qui n'ont pas vraiment leur place dans les registres généraux. Depuis les ''pixel/vertex shaders'' 3.0, les ''shaders'' sont capables d'effectuer des boucles et d'autres structures de contrôle familières pour les programmeurs. Et deux registres ont été intégrés afin d'améliorer les performances des structures de contrôle. Le premier est un registre à prédicat, qui sera vu dans la section sur le SIMD avec prédication. Le second est un '''registre compteur de boucle''', qui mémorise l'indice d'une boucle. Il est initialisé à 0, et est incrémenté à chaque fois qu'une boucle s'exécute. Certains processeurs de shader ont aussi des '''registres de texture''' , qui servent d'interface avec la mémoire pour la gestion des textures. Ils mémorisent les texels lus par l'unité de texture. L'unité de texture lit un texel, plusieurs avec ''multitexturing'', et les place dans ces registres de texture. Les registres de texture sont parfois initialisés avant l'exécution du ''shader'', mais la plupart sont initialisé quand le ''shader'' termine une instruction de lecture de texture. Ils sont généralement en lecture seule, mais il y a des exceptions. ==Les processeurs de shaders modernes : les processeurs SIMD== Maintenant, voyons quelles sont les instructions supportées par les processeurs de shaders modernes. Et si je dis moderne, c'est car nous ne parlerons que des GPU de l'époque DirectX 10 et après, pas des GPU de l'époque DirectX 9 et antérieur. La raison est que le jeu d'instruction des shaders a franchement évolué, avec le passage d'architectures VLIW à des architectures SIMD. Et cela a eu des conséquences assez profondes sur le jeu d'instruction et leur microarchitecture. Nous n'allons parler des GPU de type SIMD dans ce chapitre. Un chapitre dédié sera consacré aux GPU de type VLIW. Le jeu d'instruction des GPU NVIDIA n'est pas encore connu à l'heure où j'écris ces lignes, la documentation du constructeur n'est pas disponible. Quelques chercheurs ont tenté de faire de la rétro-ingénierie du code de divers shaders pour retrouver le jeu d'instruction des divers GPU NVIDIA, ce qui fait qu'on a cependant une idée de ce dernier. Mais rien d'officiel. Par contre, AMD fournit librement cette documentation sur le net. <noinclude>Ce qui fait qu'on peut trouver des documents de ce genre : * [https://developer.amd.com/wordpress/media/2012/12/AMD_Southern_Islands_Instruction_Set_Architecture.pdf Graphics Core Next 1 instruction set] ; * [https://developer.amd.com/wordpress/media/2013/07/AMD_Sea_Islands_Instruction_Set_Architecture.pdf Graphics Core Next 2 instruction set] ; * [https://developer.amd.com/wordpress/media/2013/12/AMD_GCN3_Instruction_Set_Architecture_rev1.1.pdf Graphics Core Next 3 and 4 instruction sets] ; * [https://developer.amd.com/wp-content/resources/Vega_7nm_Shader_ISA.pdf "Vega" 7nm instruction set architecture] (also referred to as Graphics Core Next 5.1) ; * [https://www.amd.com/content/dam/amd/en/documents/radeon-tech-docs/instruction-set-architectures/rdna3-shader-instruction-set-architecture-feb-2023_0.pdf Jeu d'instruction des GPU de type RDNA3 d'AMD].</noinclude> ===Les instructions SIMD=== Les '''instructions SIMD''' manipulent plusieurs nombres en même temps. Elles manipulent plus précisément des '''vecteurs''', des ensembles de plusieurs nombres entiers ou nombres flottants placés les uns à côté des autres, le tout ayant une taille fixe, qui sont stockés dans des registres spécialisés. En général, tous les vecteurs ont une taille fixe, peu importe leur contenu. Cela implique que suivant la taille des données à manipuler, on pourra en placer plus ou moins dans un vecteur. Par exemple, un vecteur de 128 bits pourra contenir 4 entiers de 32 bits, 4 flottants 32 bits, ou 8 entiers de 16 bits. [[File:Vector register.png|centre|vignette|upright=2|Contenu d'un vecteur en fonction du type de données utilisé.]] Les vecteurs sont stockés dans des '''registres vectoriels''', aussi appelés '''registres SIMD'''. Un registre vectoriel peut contenir un vecteur complet, pas plus. En conséquence, ils ont une taille assez importante : ils font généralement 128, 256, voire 512 bits, comparé aux 32/64 bits des registres des CPU. Les cartes graphiques modernes contiennent un très grand nombre de registres SIMD. {| |+ Comparaison entre un processeur sans registres vectoriels, et avec registres vectoriels. |[[File:Non-SIMD cpu diagram1.svg|vignette|upright=1.5|CPU Non-SIMD]] |[[File:SIMD cpu diagram1.svg|vignette|upright=1.5|CPU SIMD]] |} Une instruction SIMD traite chaque donnée du vecteur indépendamment des autres. Par exemple, une instruction d'addition vectorielle va additionner ensemble les données qui sont à la même place dans deux vecteurs, et placer le résultat dans un autre vecteur, à la même place. [[File:Instructions SIMD.png|centre|vignette|upright=2.5|Instructions SIMD]] Sur les cartes graphiques modernes, les vecteurs sont généralement des vecteurs qui regroupent plusieurs nombres flottants. De plus, les flottants en question sont des flottants dits simple précision, codés sur 32 bits. Mais il y a quelques exceptions, comme [https://www.realworldtech.com/apple-custom-gpu/ certains GPU d'Apple, qui ne gèrent majoritairement que des flottants codés sur 16 bits], avec des fonctionnalités pour la simple précision. Les anciennes cartes graphiques ne géraient pas du tout de vecteurs contenant des nombres entiers. ===Les instruction scalaires entières, typiques des CPU=== Un processeur SIMD gère donc des instructions SIMD, et les anciennes cartes graphiques ne disposaient que d'instructions de ce type. Mais depuis au moins une décennie, les processeurs de shaders gèrent des instructions normales, non-SIMD. De telles instructions sont appelées des '''instruction scalaires'''. En clair, il s'agit des instructions qu'on retrouve normalement tous les processeurs principaux (les CPU). Il s'agit généralement d''''instructions entières''', agissent sur des registres entiers non-SIMD. Elles ne traitent pas de vecteur, mais de simples nombres entiers indépendants, sans regroupement d'aucune sorte. Typiquement, il s'agit d'opérations d'addition, de soustraction, des opérations logiques, des comparaisons, guère plus. On trouve aussi des opérations un peu originales, comme des calculs de valeur absolue, du minimum/maximum de deux opérandes, des opérations à prédicat comme une instruction CMOV, etc. Les cartes graphiques supportent rarement la multiplication, mais les plus récentes supportent des multiplications sur des opérandes de 16/32 bits. Par contre, aucune ne gère de division entière. Les GPU modernes gèrent aussi des instructions de test et de branchement, là encore sur des nombres entiers. Les instructions de test et branchement sont généralement considérées comme à part des instructions de calcul, mais ce sont des opérations scalaires. Les comparaisons se font entre deux entiers scalaires, pas entre deux vecteurs. Retenez bien ce détail, car il sera très important pour la suite. Les GPU modernes gèrent aussi des '''instructions flottantes scalaires''', à savoir que des instructions qui ont pour opérandes des nombres flottants isolés, qui ne sont pas dans un vecteur. Les processeurs principaux (CPU) d'un ordinateur sont capables de faire beaucoup de calculs arithmétiques simples sur des nombres flottants, comme des additions, des multiplications, des opérations bit-à-bit, éventuellement des divisions, etc. Il en est de même sur les GPUS. Mais ces derniers gèrent aussi de nombreuses instructions flottantes que les CPU n'incorporent presque pas. Il est rare que les CPU soient capables de faire des opérations flottantes complexes, comme des calculs trigonométriques, des exponentielles, des logarithmes, des racines carrées ou racines carrées inverse, etc. De tels calculs sont rares dans les programmes exécutables, alors que les calculs arithmétiques simples y sont légion. Mais le rendu 3D demande pas mal de calculs trigonométriques, de produits scalaires ou d'autres opérations. Par exemple, dans les chapitres précédents, nous avions abordé les calculs d'éclairage et avions vu qu'ils font beaucoup de calculs vectoriels avec des vecteurs comme la normale d'un sommet. Et ces calculs demandent de calculer des produits scalaires et vectoriels, qui eux-mêmes demandent des calculs trigonométriques comme le cosinus ou le sinus. Aussi, les processeurs de ''shaders'' disposent souvent d'instructions flottantes spécialisées dans les calculs complexes : exponentielle/logarithme, racine carrée, racine carrée inverse, autres. Nous appellerons ces instructions des '''instructions transcendantales''', car elles effectuent des calculs de ce type. Il faut noter que le processeur incorpore des registres dédiés aux scalaires, séparés des registres SIMD. Par séparés, on veut dire que ce sont des registres différents, adressés différemment, mais qu'ils sont aussi physiquement séparés dans le processeur, ils sont des bancs de registres différents. ===Les instructions en ''co-issue''=== Beaucoup de cartes graphiques récentes comme anciennes incorporent des '''instructions de ''co-issue''''' qui ne se trouvent que sur les cartes graphiques et n'ont aucun équivalent sur les CPUs. Les instructions de ''co-issue'' regroupent plusieurs opérations par instruction. Par exemple, elles peuvent combiner une opération vectorielle avec une opération scalaire. Ou encore, elles peuvent regrouper une opération scalaire, une opération vectorielle et un branchement. Il s'agit d'instructions qui ressemblent grandement à ce qu'on trouve sur les processeurs VLIW. Un point important est que les cartes graphiques modernes disposent d'instructions à ''co-issue'' en plus des instructions normales. Les instructions à ''co-issue'' sont complémentaire des instructions normales, elles ne les remplacent pas. Les deux peuvent s'utiliser en même temps, dans un même shader. Il a cependant existé des cartes graphiques assez anciennes sur lesquelles toutes les instructions étaient des instructions à ''co-issue'' : certains processeurs de shaders VLIW anciens sont de ce type. Il y a de nombreuses contraintes quant au regroupement des deux opérations. On ne peut pas regrouper n'importe quelle opération avec n'importe quelle autre. L'exemple type de ''co-issue'' est la ''co-issue'' entre opérations scalaires et vectorielles : il n'est pas possible de regrouper deux instructions scalaires ou deux instructions vectorielles. La seule possibilité est de regrouper une opération scalaire et une opération vectorielle. La raison à cela est qu'opérations scalaires et vectorielles sont calculées dans des circuits séparés : le processeur incorpore une unité de calcul scalaire et une unité de calcul SIMD, et peut utiliser les deux en parallèle, en même temps. Mais nous verrons cela dans quelques chapitres. Pour simplifier, cette technique permettait d’exécuter deux opérations arithmétiques en même temps, en parallèle : une opération vectorielle appliquée aux couleurs R, G, et B, et une opération scalaire appliquée à la couleur de transparence. Si cela semble intéressant sur le papier, cela complexifie fortement le processeur de shader, ainsi que la traduction à la volée des shaders en instructions machine. ===Un exemple : le jeu d’instruction du GPU de la Geforce 3=== La première carte graphique commerciale grand public à disposer d'une unité de vertex programmable est la Geforce 3. Celui-ci respectait le format de vertex shader 1.1. L'ensemble des informations à savoir sur cette unité est disponible dans l'article [https://cseweb.ucsd.edu/~ravir/6160-fall04/papers/p149-lindholm.pdf "A user programmable vertex engine"], disponible sur le net. . Le processeur de cette carte était capable de gérer un seul type de données : les nombres flottants de norme IEEE754. Toutes les informations concernant la coordonnée d'une vertice, voire ses différentes couleurs, doivent être encodées en utilisant ces flottants. Les processeurs de vertices de la Geforce 3 disposent de registres registres SIMD qui font 128 bits, soit 4 flottants de 32 bits. Elle contient 16 registres d'entrée, 16 registres de sortie, 32 registres généraux. La mémoire des constantes contient 512 "registres". Le processeur de la Geforce 3 est capable d’exécuter 17 instructions différentes, dont voici les principales : {|class="wikitable" |- !OpCode!!Nom!!Description |- ! colspan="3" | Opérations mémoire |- |MOV||Move||vector -> vector |- |ARL||Address register load||miscellaneous |- ! colspan="3" | Opérations arithmétiques |- |ADD||Add||vector -> vector |- |MUL||Multiply||vector -> vector |- |MAD||Multiply and add||vector -> vector |- |MIN||Minimum||vector -> vector |- |MAX||Maximum||vector -> vector |- |SLT||Set on less than||vector -> vector |- |SGE||Set on greater or equal||vector -> vector |- |LOG||Log base 2||miscellaneous |- |EXP||Exp base 2||miscellaneous |- |RCP||Reciprocal||scalar-> replicated scalar |- |RSQ||Reciprocal square root||scalar-> replicated scalar |- ! colspan="3" | Opérations trigonométriques |- |DP3||3 term dot product||vector-> replicated scalar |- |DP4||4 term dot product||vector-> replicated scalar |- |DST||Distance||vector -> vector |- ! colspan="3" | Opérations d'éclairage géométrique |- |LIT||Phong lighting||Calcule l'éclairage de Gouraud |} L'instruction la plus intéressante est clairement la dernière : elle éclaire un sommet, en utilisant un éclairage de Phong. Les autres instructions permettent d'implémenter un autre algorithme si besoin, mais cette forme d'éclairage est déjà là à la base. Les autres instructions sont surtout des instructions arithmétiques : multiplications, additions, exponentielles, logarithmes, racines carrées, etc. Pour les instructions d'accès à la mémoire, on trouve une instruction MOV qui déplace le contenu d'un registre dans un autre et une instruction de calcul d'adresse, mais aucune instruction d'accès à la mémoire sur le processeur de la Geforce 3. Plus tard, les unités de ''vertex shader'' ont acquis la possibilité de lire des données dans une texture. On remarque que la division est absente. Il faut dire que la contrainte qui veut que toutes ces instructions s’exécutent en un cycle d'horloge pose quelques problèmes avec la division, qui est une opération plutôt lourde en hardware. À la place, on trouve l'instruction RCP, capable de calculer 1/x, avec x un flottant. Cela permet ainsi de simuler une division : pour obtenir Y/X, il suffit de calculer 1/X avec RCP, et de multiplier le résultat par Y. ==La prédication et le SIMT== Les cartes graphiques récentes peuvent effectuer des branchements, mais ceux-ci sont tout sauf performants. Dès qu'un branchement survient, le processeur est obligé de traiter chaque élément du vecteur un par un, au lieu de tous les traiter en même temps en parallèle. Les performances s'en ressentent, ce qui fait que les branchements sont à éviter le plus possible. Pour améliorer la gestion des conditions, les cartes graphiques modernes incorporent des instructions spécialisées qui permettent de remplacer des codes remplis de branchements par des codes plus simples, compatibles avec l'organisation des données en vecteurs. Si on met de côté le support de certaines instructions courantes, comme la valeur absolue, ou le calcul du minimum/maximum, la technique la plus importante est la technique dite de '''prédication'''. L'idée est que quand une instruction effectue un calcul sur un ou deux vecteurs, certains éléments du vecteur sont ignorés. Les éléments à ignorer sont choisis suivant le résultat d'une instruction de comparaison, qui effectue un test : les éléments pour lesquels ce test est respecté sont pris en compte, ceux qui ne passent pas le test sont ignorés. Pour donner un exemple d'utilisation, imaginons que l'on ait un vecteur dans lequel on veut remplacer toutes les valeurs négatives par des 0. Dans ce cas, on utilise : * une instruction de comparaison, qui compare chaque élément du vecteur avec 0 et génère plusieurs bits de résultat ; * suivi d'une instruction à prédicat qui met à zéro les éléments pour lesquels les bits de résultat précédents sont à 1. Elle est implémentée grâce à un registre appelé le '''''Vector Mask Register'''''. Celui-ci permet de stocker des informations qui permettront de sélectionner certaines données et pas d'autres pour faire notre calcul. Il est mis à jour par des instructions de comparaison. le ''Vector Mask Register'' stocke un bit pour chaque flottant présent dans le vecteur à traiter, bit qui indique s'il faut appliquer l'instruction sur ce flottant. Si ce bit est à 1, notre instruction doit s’exécuter sur la donnée associée à ce bit. Sinon, notre instruction ne doit pas la modifier. On peut ainsi traiter seulement une partie des registres stockant des vecteurs SIMD. [[File:Vector mask register.png|centre|vignette|upright=2.5|''Vector mask register'']] ===La prédication avec une pile SIMT=== Au niveau du jeu d’instruction, les architectures SIMT implémentent de la prédication, sous une forme améliorée. Les processeurs SIMT actuels sont surtout utilisées sur les processeurs intégrés aux cartes graphiques. Et ces derniers gèrent très mal les branchements, et encore : beaucoup de cartes graphiques, même récentes, ne gèrent tout simplement pas les branchements. Elles doivent donc se débrouiller avec uniquement la prédication, là où les processeurs SIMD utilisent des branchements normaux en complément de la prédication. Insistons sur le fait que cet usage exclusif de la prédication n'est présent que sur une sous-partie des architectures SIMT, le seul exemple que l'auteur de ce wikilivre connait étant celui des cartes graphiques. Les architectures SIMT sans branchements doivent donc trouver des solutions pour gérer les structures de contrôle imbriquées, à savoir une boucle placée à l'intérieur d'une autre boucle, un IF...ELSE dans un autre IF...ELSE, etc. Elles utilisent pour cela la prédication, combinée avec des mécanismes annexes. Le premier d'entre eux est l'usage de plusieurs registres de masques organisés d'une manière bien précise, l'autre est l'usage de compteurs d'activité. Voyons ces deux techniques. La '''pile de masques''' remplace le ou les registres de masque. Sans elle, le processeur SIMD incorpore un registre de masque qui est adressé implicitement ou explicitement. Éventuellement, le processeur peut contenir plusieurs registres de masque séparés adressables via un nom de registre. Avec elle, le processeur SIMD incorpore plusieurs registres de masque organisé en pile. Le registre de masque est donc remplacé par une mémoire LIFO, une pile, dans laquelle plusieurs masques sont empilés. Le tout forme une pile, similaire à la pile d'appel, sauf qu'elle est utilisée pour empiler des masques. Un masque est calculé et empilé à chaque entrée dans une structure de contrôle, puis dépilé une fois la structure de contrôle exécutée. L'empilement et le dépilement des masques est effectué par des instructions PUSH et POP, présentes dans le jeu d'instruction du processeur SIMD. Le calcul des masques doit répondre à plusieurs impératifs. * Premièrement, chaque masque se calcule en faisant un ET entre le masque précédent et le masque calculé par l'instruction de test. Cela permet de ne pas réveiller d’élément au beau milieu d'une structure imbriquée. Si in IF désactive certains éléments du vecteur, une condition imbriquée dans ce IF ne doit pas réveiller cet élément. Le fait de faire un ET entre les masques garantit cela. * Deuxièmement, les masques doivent être empilés et dépilés correctement. Au moment de rentrer dans une structure de contrôle, on effectue une instruction de test associée à la structure de contrôle, qui calcule un masque, et on empile le masque calculé. Au moment de sortir de la structure de contrôle, on dépile le masque en question. L'implémentation demande d'utiliser une mémoire LIFO pour stocker la pile de masques, et quelques circuits annexes. Il faut notamment un circuit relié à l'ALU qui récupère les conditions, les résultats des comparaisons, et qui effectue le ET pour combiner les masques. Pour donner un exemple, prenons le code suivant, qui est volontairement simpliste et ne sert qu'à des fins d'explication : <syntaxhighlight lang="c"> if ( condition 1 ) { if ( condition 2 ) { ... } else { ... } Autres instructions } Instructions après le IF... </syntaxhighlight> Imaginons que l'on traite des vecteurs de 8 éléments. Pour le vecteur considéré, la première condition (a > 0) n'est respectée que par les 4 premiers éléments. L'instruction de condition calcule alors le masque correspondant : 1111 0000. Le masque est alors calculé, puis empilé au sommet de la pile. La seconde instruction de test, qui teste la variable b, est maintenant valide pour les 4 bits du milieu du masque. Mais n'allez pas croire que le masque correspondant soit 0011 11100 : il faut tenir compte de la condition précédente, qui a éliminé les 4 derniers éléments. Pour cela, on fait un ET logique entre le masque précédent, et le masque calculé par la condition. Le masque au sommet de la pile est donc lu, combiné avec le masque calculé par l'instruction, ce qui donne le masque final. Le masque final est alors empilé au sommet de la pile. On exécute alors l'instruction du IF, en tenant compte du masque qui est au sommet de la pile. Si le IF était plus compliqué, toutes les instructions suivantes tiendraient compte du masque. En fait, le masque est pris en compte tant qu'il n'est pas dépilé. Une fois que le IF est terminé, le masque est dépilé. On passe alors au ELSE, et rebelotte. Le masque pour le ELSE est calculé en combinant le masque au sommet de la pile avec la condition du ELSE. Le masque au sommet de la pile est celui calculé à l'entrée du premier IF, pas le second qui a été dépilé. Les instructions du ELSE sont alors exécutées en tenant compte de ce masque. Une fois qu'elles sont toutes exécutées, le masque est dépilé. Puis vient l'exécution des instructions après le ELSE. Elles utilisent le masque empilé au sommet de la pile, qui correspond à celui à l'entrée du IF. Puis vient le moment d'exécuter les instructions après le IF : pas de masque, on exécute sur tout le vecteur. ===Les compteurs d'activité=== Une variante de la technique précédente remplace la pile de masques par des '''compteurs d'activité'''. La technique est similaire, si ce n'est qu'elle utilise moins de circuits. Avant , on avait une pile de masques de même taille, dont les bits sont à 0 ou 1 suivant que la condition est remplie. La pile de masque ressemble donc à ceci : {|class="wikitable" |- ! masque 1 | 1 || 1 || 1 || 1 |- ! masque 2 | 0 || 1 || 1 || 1 |- ! masque 3 | 0 || 1 || 1 || 1 |- ! masque 4 | 0 || 0 || 0 || 1 |- ! masque 1 | colspan="4" | vide |} Une manière équivalente de représenter cette pile de masque est de compter combien de bits sont à 0 dans chaque colonne. Attention : j'ai bien dit à 0 ! On obtient alors : {|class="wikitable" |- ! masque 1 | 3 || 1 || 1 || 0 |} Et c'est le principe caché derrière la technique des compteurs d'activité. Chaque élément dans un vecteur, chaque place, se voit attribuer un compteur. Un compteur non-nul indique qu'il ne faut pas prendre en compte l’élément. Ce n'est qu'une fois que le compteur est nul que l'on effectue des opérations sur l’élément associé du vecteur. À chaque fois qu'on entre dans une structure de contrôle, on teste une condition sur chaque élément. Si la condition est respectée pour un élément, alors le compteur ne change pas. Mais si la condition n'est pas respectée, alors on incrémente le compteur associé. En sortant de la structure de contrôle, on décrémente le compteur associé. Notons que les compteurs qui n'ont pas été incrémentés en entrant dans la structure de contrôle ne sont pas décrémentés en sortant. En clair, là où on empilait/dépilait un masque, on se contente d'incrémenter/décrémenter un compteur. Utiliser un compteur en lieu et place d'une colonne entière dans la pile de masque utilise moins de bits. Et c'est sans doute pour cette raison que certaines cartes graphiques, comme les cartes graphiques intégrées d'Intel depuis 2004, utilisent cette technique. <noinclude> {{NavChapitre | book=Les cartes graphiques | prev=L'évolution vers la programmabilité : les GPUs | prevText=L'évolution vers la programmabilité : les GPUs | next=La microarchitecture des processeurs de shaders | nextText=La microarchitecture des processeurs de shaders }}{{autocat}} </noinclude> 2hdtocd6irpb7gylwj2q9txdeui2rkj Mathc initiation/Fichiers h : c59 0 76713 765199 764740 2026-04-27T11:12:42Z Xhungab 23827 765199 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] : [[Mathc initiation/005i| Sommaire]] : {{Partie{{{type|}}}| Le théorème de Stoke (version II) }} En mathématiques, et plus particulièrement en géométrie différentielle, le théorème de Stokes est un résultat central sur l'intégration des formes différentielles, qui généralise le second théorème fondamental de l'analyse, ainsi que de nombreux théorèmes d'analyse vectorielle. [https://fr.khanacademy.org/math/multivariable-calculus/greens-theorem-and-stokes-theorem/stokes-theorem/v/stokes-theorem-intuition Khanacademy : stokes-theorem-intuition] ... [https://fr.khanacademy.org/math/multivariable-calculus/greens-theorem-and-stokes-theorem/stokes-proof/v/stokes-theorem-proof-part-1 Khanacademy : stokes-theorem-proof] Le théorème de Stoke (version II) a) dS = [(f_x)^2+(f_y)^2+1]^1/2 dA dA = dxdy // || || (curl F).n dS = (-f_xi-f_yj+k) || b) n = ----------------------- // [(f_x)^2+(f_y)^2+1]^1/2 S n: dS: // // || || ( (-f_xi-f_yj+k) || (curl F).n dS = || (curl F).( ------------------------ [(f_x)^2+(f_y)^2+1]^1/2 dA || || ( [(f_x)^2+(f_y)^2+1]^1/2) // // S S Si vous simplifiez par [(f_x)^2+(f_y)^2+1]^1/2 vous allez obtenir la version I Copier la bibliothèque dans votre répertoire de travail : * [[Mathc initiation/Fichiers h : c59a1|x_afile.h ............ Déclaration des fichiers h]] * [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]] * [[Mathc initiation/Fichiers c : c47ca|x_strcp.h ........... Déclaration des structures (points, vecteurs)]] * [[Mathc initiation/Fichiers h : c23a3|x_fx.h ................ Calculer les dérivées]] * [[Mathc initiation/Fichiers h : c25a4|x_fxy.h]] * [[Mathc initiation/Fichiers h : c26a4|x_fxyz.h]] * [[Mathc initiation/Fichiers h : c59a7|x_l3d_dx.h ......... L'intégrale curviligne 3d]] * [[Mathc initiation/Fichiers h : c59a8|x_l3d_dy.h ]] * [[Mathc initiation/Fichiers h : c59a9|x_l3d_dz.h ]] * [[Mathc initiation/Fichiers h : c59aa|x_nxy.h ............. n = (-f_xi-f_yj+k) / [(f_x)^2+(f_y)^2+1]^1/2 ]] * [[Mathc initiation/Fichiers h : c59ab|x_curl.h ............. Calculer le rotationel ]] * [[Mathc initiation/Fichiers h : c59ac|x_stokxy.h ........... L'intégrale de Stoke ]] * [[Mathc initiation/c36a1|x_stokyx.h]] les fonctions f : * [[Mathc initiation/Fichiers h : c59fa|f.h]] Résolution avec : * [[Mathc initiation/Fichiers c : c59ca1|c0a1.c .............. L'intégrale de Stoke dxdy .... s = 113.081]] * [[Mathc initiation/a470|c0a2.c .............. Les intégrales curviligne ...... s = +113.097]] * [[Mathc initiation/Fichiers c : c59ca2|c0a3.c .............. L'intégrale de Stoke '''dydx''' .... s = 113.081]] * [[Mathc initiation/Fichiers c : c59cb1|c0b1.c .............. L'intégrale de Stoke dxdy .... s = -12.579]] * [[Mathc initiation/Fichiers c : c59cb2|c0b2.c .............. Les intégrales curviligne ...... s = -12.566]] * [[Mathc initiation/004x|c0b3.c .............. L'intégrale de Stoke '''dydx''' .... s = -12.579]] Regardons la fonction qui effectue le travail : * [[Mathc initiation/c57a1| Étudions la fonction '''stokes_dxdy();''']] {{AutoCat}} s1y40735ld96zybprtvcnwiysst83qe Mathc initiation/Fichiers h : c59ab 0 76724 765195 761305 2026-04-27T10:31:19Z Xhungab 23827 765195 wikitext text/x-wiki [[Catégorie:Mathc initiation (livre)]] Installer ce fichier dans votre répertoire de travail. {{Fichier|x_curl.h|largeur=70%|info=|icon=Crystal Clear mimetype source h.png}} <syntaxhighlight lang="c"> /* ---------------------------------- */ /* save as x_curl.h */ /* ---------------------------------- */ /* with F = Mi + Nj + Pk (curl F) = [(P_y-N_z)i + (M_y-P_z)j + (N_X-M_Y)k] */ /* ---------------------------------- */ v3d curl_ijk( double (*P_M)(double x, double y, double z), double (*P_N)(double x, double y, double z), double (*P_P)(double x, double y, double z), pt3d p ) { v3d curl; curl.i = fxyz_y((*P_P),H,p) - fxyz_z((*P_N),H,p); curl.j = fxyz_z((*P_M),H,p) - fxyz_x((*P_P),H,p); curl.k = fxyz_x((*P_N),H,p) - fxyz_y((*P_M),H,p); return(curl); } /* ---------------------------------- */ /* ---------------------------------- */ </syntaxhighlight> Déclaration des fichiers h. {{AutoCat}} svesup6323xzx07ete34t0o70a6a9o1 765196 765195 2026-04-27T10:33:23Z Xhungab 23827 765196 wikitext text/x-wiki [[Catégorie:Mathc initiation (livre)]] Installer ce fichier dans votre répertoire de travail. {{Fichier|x_curl.h|largeur=70%|info=|icon=Crystal Clear mimetype source h.png}} <syntaxhighlight lang="c"> /* ---------------------------------- */ /* save as x_curl.h */ /* ---------------------------------- */ /* with F = Mi + Nj + Pk | i j k | | _x _y _z | | M N P | (curl F) = [(P_y-N_z)i + (M_y-P_z)j + (N_X-M_Y)k] */ /* ---------------------------------- */ v3d curl_ijk( double (*P_M)(double x, double y, double z), double (*P_N)(double x, double y, double z), double (*P_P)(double x, double y, double z), pt3d p ) { v3d curl; curl.i = fxyz_y((*P_P),H,p) - fxyz_z((*P_N),H,p); curl.j = fxyz_z((*P_M),H,p) - fxyz_x((*P_P),H,p); curl.k = fxyz_x((*P_N),H,p) - fxyz_y((*P_M),H,p); return(curl); } /* ---------------------------------- */ /* ---------------------------------- */ </syntaxhighlight> Déclaration des fichiers h. {{AutoCat}} fkqj95l3qh34ea5fxtlu861un07tw7j Les cartes graphiques/Le rendu d'une scène 3D : concepts de base 0 79234 765144 764879 2026-04-26T20:02:25Z Mewtow 31375 /* La différence entre rastérisation et lancer de rayons */ 765144 wikitext text/x-wiki Le premier jeu à utiliser de la "vraie 3D" texturée fut le jeu Quake, premier du nom. Et depuis sa sortie, la grande majorité des jeux vidéo utilisent de la 3D, même s'il existe encore quelques jeux en 2D. Face à la prolifération des jeux vidéo en 3D, les fabricants de cartes graphiques ont inventé les cartes accélératrices 3D, des cartes vidéo capables d'accélérer le rendu en 3D. Dans ce chapitre, nous allons voir comment elles fonctionnent et comment elles ont évolué dans le temps. Pour comprendre comment celles-ci fonctionnent, il faut faire quelques rapides rappels sur les bases du rendu 3D. ==Les bases du rendu 3D== Une '''scène 3D''' est composée d'un espace en trois dimensions, dans laquelle le moteur d’un jeu vidéo place des objets et les fait bouger. Cette scène est, en première approche, un simple parallélogramme. Un des coins de ce parallélogramme sert d’origine à un système de coordonnées : il est à la position (0, 0, 0), et les axes partent de ce point en suivant les arêtes. Les objets seront placés à des coordonnées bien précises dans ce parallélogramme. ===Les objets 3D et leur géométrie=== <noinclude>[[File:Dolphin triangle mesh.png|vignette|Illustration d'un dauphin, représenté avec des triangles.]]</noinclude> Dans la quasi-totalité des jeux vidéo actuels, les objets et la scène 3D sont modélisés par un assemblage de triangles collés les uns aux autres, ce qui porte le nom de '''maillage''', (''mesh'' en anglais). Il a été tenté dans le passé d'utiliser des quadrilatères (rendu dit en ''quad'') ou d'autres polygones, mais les contraintes techniques ont fait que ces solutions n'ont pas été retenues. [[File:CG WIKI.jpg|centre|vignette|upright=2|Exemple de modèle 3D.]] Les modèles 3D sont définis par leurs sommets, aussi appelés '''vertices''' dans le domaine du rendu 3D. Chaque sommet possède trois coordonnées, qui indiquent sa position dans la scène 3D : abscisse, ordonnée, profondeur. Les sommets sont regroupés en triangles, qui sont formés en combinant trois sommets entre eux. Les anciennes cartes graphiques géraient aussi d'autres formes géométriques, comme des points, des lignes, ou des quadrilatères. Les quadrilatères étaient appelés des ''quads'', et ce terme reviendra occasionnellement dans ce cours. De telles formes basiques, gérées nativement, sont appelées des '''primitives'''. La représentation exacte d'un objet est donc une liste plus ou moins structurée de sommets. La liste doit préciser les coordonnées de chaque sommet, ainsi que comment les relier pour former des triangles. Pour cela, l'objet est représenté par une structure qui contient la liste des sommets, mais aussi de quoi savoir quels sont les sommets reliés entre eux par un segment. Nous en dirons plus dans le chapitre sur le rendu de la géométrie. ===La caméra : le point de vue depuis l'écran=== Outre les objets proprement dit, on trouve une '''caméra''', qui représente les yeux du joueur. Cette caméra est définie au minimum par : * une position ; * par la direction du regard (un vecteur). A la caméra, il faut ajouter tout ce qui permet de déterminer le '''champ de vision'''. Le champ de vision contient tout ce qui est visible à l'écran. Et sa forme dépend de la perspective utilisée. Dans le cas le plus courant dans les jeux vidéos en 3D, il correspond à une '''pyramide de vision''' dont la pointe est la caméra, et dont les faces sont délimitées par les bords de l'écran. A l'intérieur de la pyramide, il y a un rectangle qui représente l'écran du joueur, appelé le '''''viewport'''''. <noinclude>[[File:ViewFrustum.jpg|centre|vignette|upright=2|Caméra.]]</noinclude> [[File:ViewFrustum.svg|vignette|upright=1|Volume délimité par la caméra (''view frustum'').]] La majorité des jeux vidéos ajoutent deux plans : * un ''near plane'' en-deça duquel les objets ne sont pas affichés. Il élimine du champ de vision les objets trop proches. * Un ''far plane'', un '''plan limite''' au-delà duquel on ne voit plus les objets. Il élimine les objets trop lointains. Avec ces deux plans, le champ de vision de la caméra est donc un volume en forme de pyramide tronquée, appelé le '''''view frustum'''''. Le tout est parfois appelée, bien que par abus de langage, la pyramide de vision. Avec d'autres perspectives moins utilisées, le ''view frustum'' est un pavé, mais nous n'en parlerons pas plus dans le cadre de ce cours car elles ne sont presque pas utilisés dans les jeux vidéos actuels. ===Les textures=== Tout objet à rendre en 3D est donc composé d'un assemblage de triangles, et ceux-ci sont éclairés et coloriés par divers algorithmes. Pour rajouter de la couleur, les objets sont recouverts par des '''textures''', des images qui servent de papier peint à un objet. Un objet géométrique est donc recouvert par une ou plusieurs textures qui permettent de le colorier ou de lui appliquer du relief. [[File:Texture+Mapping.jpg|centre|vignette|upright=2|Texture Mapping]] Notons que les textures sont des images comme les autres, codées pixel par pixel. Pour faire la différence entre les pixels de l'écran et les pixels d'une texture, on appelle ces derniers des '''texels'''. Ce terme est assez important, aussi profitez-en pour le mémoriser, nous le réutiliserons dans quelques chapitres. Un autre point lié au fait que les textures sont des images est leur compression, leur format. N'allez pas croire que les textures sont stockées dans un fichier .jpg, .png ou tout autre format de ce genre. Les textures utilisent des formats spécialisés, comme le DXTC1, le S3TC ou d'autres, plus adaptés à leur rôle de texture. Mais qu'il s'agisse d'images normales (.jpg, .png ou autres) ou de textures, toutes sont compressées. Les textures sont compressées pour prendre moins de mémoire. Songez que la compression de texture est terriblement efficace, souvent capable de diviser par 6 la mémoire occupée par une texture. S'en est au point où les textures restent compressées sur le disque dur, mais aussi dans la mémoire vidéo ! Nous en reparlerons dans le chapitre sur la mémoire d'une carte graphique. Plaquer une texture sur un objet peut se faire de deux manières, qui portent les noms de placage de texture inverse et direct. Le placage de texture direct a été utilisé au tout début de la 3D, sur des bornes d'arcade et les consoles de jeu 3DO, PS1, Sega Saturn. De nos jours, on utilise uniquement la technique de placage de texture inverse. Les deux seront décrites dans le détail plus bas. ===La différence entre rastérisation et lancer de rayons=== Les techniques de rendu 3D sont nombreuses, mais on peut les classer en deux grands types : le ''lancer de rayons'' et la ''rasterization''. Sans décrire les deux techniques, sachez cependant que le lancer de rayon n'est pas beaucoup utilisé pour les jeux vidéo. Il est surtout utilisé dans la production de films d'animation, d'effets spéciaux, ou d'autres rendu spéciaux. Dans les jeux vidéos, il est surtout utilisé pour quelques effets graphiques, la rasterization restant le mode de rendu principal. La raison principale est que le lancer de rayons demande beaucoup de puissance de calcul. Une autre raison est que créer des cartes accélératrices pour le lancer de rayons n'est pas simple. Il a existé des cartes accélératrices permettant d'accélérer le rendu en lancer de rayons, mais elles sont restées confidentielles. Les cartes graphiques modernes incorporent quelques circuits pour accélérer le lancer de rayons, mais ils restent d'un usage marginal et servent de compléments au rendu par rastérization. Un chapitre entier sera dédié aux cartes accélératrices de lancer de rayons et nous verrons pourquoi le lancer de rayons est difficile à implémenter avec des performances convenables, ce qui explique que les jeux vidéo utilisent la ''rasterization''. La rastérisation est structurée autour de trois étapes principales : * Une étape purement logicielle, effectuée par le processeur, où le moteur physique calcule la géométrie de la scène 3D. * Une étape de '''traitement de la géométrie''', qui gère tout ce qui a trait aux sommets et triangles. * Une étape de '''rastérisation''' qui détermine sur quels pixels de l'écran est affiché le triangle. * Une étape de '''traitement des pixels''', qui colorie les pixels et gère les textures. [[File:Graphics pipeline 2 en.svg|centre|vignette|upright=2.5|Pipeline graphique basique.]] Il existe plusieurs rendus différents et la rastérisation ne se fait pas de la même manière selon le rendu utilisé. Il existe des rendus sans textures, d'autres avec, d'autres avec éclairage, d'autres sans, etc. Par contre, l'étape de calcul de la géométrie est la même quel que soit le rendu ! Mieux : le calcul de la géométrie se fait de la même manière entre rastérisation et lancer de rayons, il est le même quelle que soit la technique de rendu 3D utilisée. Les trois étapes précédentes sont réalisées dans des circuits ou processeurs séparés, comme on le verra plus tard. Et cela permet d'utiliser la technique dite du '''pipeline'''. Concrètement, supposons que la carte graphique traite les données par paquets de triangles (en réalité, c'est des paquets de sommets, mais passons). L'étape de traitement de la géométrie peut travailler sur un paquet de triangle, pendant que le paquet précédent est dans l'étape de rastérisation, et que le paquet encore précédent est en train de traiter ses pixels. Cela permet de traiter trois paquets de triangles en même temps, mais à des états d'avancements différents. Mieux que cela : le traitement de la géométrie est lui-même composé d'une succession de sous-étapes, la rasterisation est elle-même découpée en plusieurs sous-étapes, et ainsi de suite. Le nombre d'étapes pour une carte graphique moderne dépasse la dizaine. La rastérisation calcule un rendu 3D avec une suite d'étapes consécutives qui doivent s'enchainer dans un ordre bien précis. L'ensemble de ces étapes est appelé le '''pipeline graphique''', qui sera détaillé dans ce qui suit. ==Le calcul de la géométrie== Le calcul de la géométrie regroupe plusieurs manipulations différentes. La principale demande juste de placer les modèles 3D dans la scène, de placer les objets dans le monde. Puis, il faut centrer la scène 3D sur la caméra. Les deux changements ont pour point commun de demander des changements de repères. Par changement de repères, on veut dire que l'on passe d'un système de coordonnées à un autre. En tout, il existe trois changements de repères distincts qui sont regroupés dans l''''étape de transformation''' : un premier qui place chaque objet 3D dans la scène 3D, un autre qui centre la scène du point de vue de la caméra, et un autre qui corrige la perspective. ===Les trois étapes de transformation=== La première étape place les objets 3D dans la scène 3D. Un modèle 3D est représentée par un ensemble de sommets, qui sont reliés pour former sa surface. Les données du modèle 3D indiquent, pour chaque sommet, sa position par rapport au centre de l'objet qui a les coordonnées (0, 0, 0). La première étape place l'objet 3D à une position dans la scène 3D, déterminée par le moteur physique, qui a des coordonnées (X, Y, Z). Une fois placé dans la scène 3D, le centre de l'objet passe donc des coordonnées (0, 0, 0) aux coordonnées (X, Y, Z) et tous les sommets de l'objet doivent être mis à jour. De plus, l'objet a une certaine orientation : il faut aussi le faire tourner. Enfin, l'objet peut aussi subir une mise à l'échelle : on peut le gonfler ou le faire rapetisser, du moment que cela ne modifie pas sa forme, mais simplement sa taille. En clair, le modèle 3D subit une translation, une rotation et une mise à l'échelle, les trois impliquant une modification des coordonnées des sommets.. [[File:Similarity and congruence transformations.svg|centre|vignette|upright=1.5|Transformations géométriques possibles pour chaque triangle.]] Une fois le placement des différents objets effectué, la carte graphique effectue un changement de coordonnées pour centrer le monde sur la caméra. Au lieu de considérer un des bords de la scène 3D comme étant le point de coordonnées (0, 0, 0), il va passer dans le référentiel de la caméra. Après cette transformation, le point de coordonnées (0, 0, 0) sera la caméra. La direction de la vue du joueur sera alignée avec l'axe de la profondeur (l'axe Z). [[File:View transform.svg|centre|vignette|upright=2|Étape de transformation dans un environnement en deux dimensions : avant et après. On voit que l'on centre le monde sur la position de la caméra et dans sa direction.]] Enfin, il faut aussi corriger la perspective, ce qui est le fait de l'étape de projection, qui modifie la forme du ''view frustum'' sans en modifier le contenu. Différents types de perspective existent et celles-ci ont un impact différent les unes des autres sur le ''view frustum''. Dans le cas qui nous intéresse, le ''view frustum'' passe d’une forme de trapèze tridimensionnel à une forme de pavé dont l'écran est une des faces. ===Les changements de coordonnées se font via des multiplications de matrices=== Les trois étapes précédentes demande de faire des changements de coordonnées, chaque sommet voyant ses coordonnées remplacées par de nouvelles. Or, un changement de coordonnée s'effectue assez simplement, avec des matrices, à savoir des tableaux organisés en lignes et en colonnes avec un nombre dans chaque case. Un changement de coordonnées se fait simplement en multipliant le vecteur (X, Y, Z) des coordonnées d'un sommet par une matrice adéquate. Il existe des matrices pour la translation, la mise à l'échelle, d'autres pour la rotation, une autre pour la transformation de la caméra, une autre pour l'étape de projection, etc. Un changement de coordonnée s'effectue assez simplement en multipliant le vecteur-coordonnées (X, Y, Z) d'un sommet par une matrice adéquate. Un petit problème est que les matrices qui le permettent sont des matrices avec 4 lignes et 4 colonnes. Or, la multiplication demande que le nombre de coordonnées du vecteur soit égal au nombre de colonnes. Pour résoudre ce petit problème, on ajoute une 4éme coordonnée aux sommets, la coordonnée homogène, qui ne sert à rien, et est souvent mise à 1, par défaut. Mais oublions ce détail. Il se trouve que multiplier des matrices amène certaines simplifications. Au lieu de faire plusieurs multiplications de matrices, il est possible de fusionner les matrices en une seule, ce qui permet de simplifier les calculs. Ce qui fait que le placement des objets, changement de repère pour centrer la caméra, et d'autres traitements forts différents sont regroupés ensemble. Le traitement de la géométrie implique, sans surprise, des calculs de géométrie dans l'espace. Et cela implique des opérations mathématiques aux noms barbares : produits scalaires, produits vectoriels, et autres calculs impliquant des vecteurs et/ou des matrices. Et les calculs vectoriels/matriciels impliquent beaucoup d'additions, de soustractions, de multiplications, de division, mais aussi des opérations plus complexes : calculs trigonométriques, racines carrées, inverse d'une racine carrée, etc. Au final, un simple processeur peut faire ce genre de calculs, si on lui fournit le programme adéquat, l'implémentation est assez aisée. Mais on peut aussi implémenter le tout avec un circuit spécialisé, non-programmable. Les deux solutions sont possibles, tant que le circuit dispose d'assez de puissance de calcul. Les cartes graphiques anciennes contenaient un ou plusieurs circuits de multiplication de matrices spécialisés dans l'étape de transformation. Chacun de ces circuits prend un sommet et renvoie le sommet transformé. Ils sont composés d'un gros paquet de multiplieurs et d'additionneurs flottants. Pour plus d'efficacité, les cartes graphiques comportent plusieurs de ces circuits, afin de pouvoir traiter plusieurs sommets en même temps. ==L'élimination des surfaces cachées== Un point important du rendu 3D est que ce que certaines portions de la scène 3D ne sont pas visibles depuis la caméra. Et idéalement, les portions de la scène 3D qui ne sont pas visibles à l'écran ne doivent pas être calculées. A quoi bon calculer des choses qui ne seront pas affichées ? Ce serait gâcher de la puissance de calcul. Et pour cela, de nombreuses optimisations visent à éliminer les calculs inutiles. Elles sont regroupées sous les termes de '''''clipping''''' ou de '''''culling'''''. La différence entre ''culling'' et ''clipping'' n'est pas fixée et la terminologie n'est pas claire. Dans ce qui va suivre, nous n'utiliserons que le terme ''culling''. Les cartes graphiques modernes embarquent diverses méthodes de ''culling'' pour abandonner les calculs quand elles s’aperçoivent que ceux-ci portent sur une partie non-affichée de l'image. Cela fait des économies de puissance de calcul assez appréciables et un gain en performance assez important. Précisons que le ''culling'' peut être plus ou moins précoce suivant le type de rendu 3D utilisé, mais nous verrons cela dans la suite du chapitre. ===Les différentes formes de ''culling''/''clipping''=== La première forme de ''culling'' est le '''''view frustum culling''''', dont le nom indique qu'il s'agit de l'élimination de tout ce qui est situé en-dehors du ''view frustum''. Ce qui est en-dehors du champ de vision de la caméra n'est pas affiché à l'écran n'est pas calculé ou rendu, dans une certaine mesure. Le ''view frustum culling'' est assez trivial : il suffit d'éliminer ce qui n'est pas dans le ''view frustum'' avec quelques calculs de coordonnées assez simples. Quelques subtilités surviennent quand un triangle est partiellement dans le ''view frustrum'', ce qui arrive parfois si le triangle est sur un bord de l'écran. Mais rien d'insurmontable. [[File:View frustum culling.svg|centre|vignette|upright=1|''View frustum culling'' : les parties potentiellement visibles sont en vert, celles invisibles en rouge et celles partiellement visibles en bleu.]] Les autres formes de ''culling'' visent à éliminer ce qui est dans le ''view frustum'', mais qui n'est pas visible depuis la caméra. Pensez à des objets cachés par un autre objet plus proche, par exemple. Ou encore, pensez aux faces à l'arrière d'un objet opaque qui sont cachées par l'avant. Ces deux cas correspondent à deux types de ''culling''. L'élimination des objets masqués par d'autres est appelé l'''occlusion culling''. L'élimination des parties arrières d'un objet est appelé le ''back-face culling''. Dans les deux cas, nous parlerons d''''élimination des surfaces cachées'''. [[File:Occlusion culling example PL.svg|centre|vignette|''Occlusion culling'' : les objets en bleu sont visibles, ceux en rouge sont masqués par les objets en bleu.]] Le lancer de rayons n'a pas besoin d'éliminer les surfaces cachées, il ne calcule que les surfaces visibles. Par contre, la rastérisation demande d'éliminer les surfaces cachées. Sans cela, le rendu est incorrect dans le pire des cas, ou alors le rendu calcule des surfaces invisibles pour rien. Il existe de nombreux algorithmes logiciels pour implémenter l'élimination des surfaces cachées, mais la carte graphique peut aussi s'en charger. L'''occlusion culling'' demande de connaitre la distance à la caméra de chaque triangle. La distance à la caméra est appelée la '''profondeur''' du triangle. Elle est déterminée à l'étape de rastérisation et est calculée à chaque sommet. Lors de la rastérisation, chaque sommet se voit attribuer trois coordonnées : deux coordonnées x et y qui indiquent sa position à l'écran, et une coordonnée de profondeur notée z. ===L'algorithme du peintre=== Pour éliminer les surfaces cachées, la solution la plus simple consiste simplement à rendre les triangles du plus lointain au plus proche. L'idée est que si deux triangles se recouvrent totalement ou partiellement, on doit dessiner celui qui est derrière, puis celui qui est devant. Le dessin du second va recouvrir le premier. Quelque chose qui devrait vous rappeler le rendu 2D, où les sprites sont rendus du plus lointain au plus proche. Il ne s'agit ni plus ni moins que de l''''algorithme du peintre'''. [[File:Polygons cross.svg|vignette|Polygons cross]] <noinclude>[[File:Painters problem.svg|vignette|Painters problem]]</noinclude> Un problème est que la solution ne marche pas avec certaines configurations particulières, dans le cas où des polygones un peu complexes se chevauchent plusieurs fois. Il se présente rarement dans un rendu 3D normal, mais c'est quand même un cas qu'il faut gérer. Le problème est suffisant pour que cette solution ne soit plus utilisée dans le rendu 3D normal. Un autre problème est que l'algorithme demande de trier les triangles d'une scène 3D selon leur profondeur, du plus profond au moins profond. Et les cartes graphiques n'aiment pas ça, que ce soit les anciennes cartes graphiques comme les modernes. Il s'agit généralement d'une tâche qui est réalisée par le processeur, le CPU, qui est plus efficace que le GPU pour trier des trucs. Aussi, l'algorithme du peintre était utilisé sur d'anciennes cartes graphiques, qui ne géraient pas la géométrie mais seulement les textures et quelques effets de post-processing. Avec ces GPU, les jeux vidéo calculaient la géométrie et la triait sur le CPU, puis effectuaient le reste de la rastérisation sur le GPU. Les anciens jeux en 2.5D comme DOOM ou les DOOM-like, utilisaient une amélioration de l'algorithme du peintre. L'amélioration variait suivant le moteur de jeu utilisé, et donnait soit une technique dite de ''portal rendering'', soit un système de ''Binary Space Partionning'', assez complexes et difficiles à expliquer. Mais il ne s'agissait pas de jeux en 3D, les maps de ces jeux avaient des contraintes qui rendaient cette technique utilisable. Ils n'avaient pas de polygones qui se chevauchent, notamment. ===Le tampon de profondeur=== [[File:Z-buffer no text.jpg|vignette|Z-buffer correspondant à un rendu]] Une autre solution utilise ce qu'on appelle un '''tampon de profondeur''', aussi appelé un ''z-buffer''. Il s'agit d'un tableau, stocké en mémoire vidéo, qui mémorise la coordonnée z de l'objet le plus proche pour chaque pixel. Par défaut, ce tampon de profondeur est initialisé avec la valeur de profondeur maximale, celle du ''far plane'' du ''viewfrustum''. Au fur et à mesure que les objets seront calculés, le tampon de profondeur est mis à jour, conservant ainsi la trace de l'objet le plus proche de la caméra. Si jamais un triangle a une coordonnée z plus grande que celle du tampon de profondeur, cela veut dire qu'il est situé derrière un objet déjà rendu. Il est éliminé (sauf si transparence il y a) et le tampon de profondeur n'a pas à être mis à jour. Dans le cas contraire, l'objet est plus près de la caméra et sa coordonnée z remplace l'ancienne valeur z dans le tampon de profondeur. [[File:Z-buffer.svg|centre|vignette|upright=2.0|Illustration du processus de mise à jour du Z-buffer.]] Il existe des techniques alternatives pour coder la coordonnée de profondeur, qui se distinguent par le fait que la coordonnée z n'est pas proportionnelle à la distance entre le fragment et la caméra. Mais il s'agit là de détails assez mathématiques que je me permets de passer sous silence. Dans la suite de ce cours, nous allons juste parler de profondeur pour regrouper toutes ces techniques, conventionnelles ou alternatives. Toutes les cartes graphiques modernes utilisent un système de ''z-buffer''. C'est la seule solution pour avoir des performances dignes de ce nom. Il faut cependant noter qu'elles utilisent des tampons de profondeur légèrement modifiés, qui ne mémorisent pas la coordonnée de profondeur, mais une valeur dérivée. Pour simplifier, ils ne mémorisent pas la coordonnée de profondeur z, mais son inverse 1/z. Les raisons à cela ne peuvent pas encore être expliquées à ce moment du cours, aussi nous allons simplement dire que c'est une histoire de correction de perspective. Les coordonnées z et 1/z sont codées sur quelques bits, allant de 16 bits pour les anciennes cartes graphiques, à 24/32 bits pour les cartes plus récentes. De nos jours, les Z-buffer de 16 bits sont abandonnés et toutes les cartes graphiques utilisent des coordonnées z de 24 à 32 bits. La raison est que les Z-buffer de 16 bits ont une précision insuffisante, ce qui fait que des artefacts peuvent survenir. Si deux objets sont suffisamment proches, le tampon de profondeur n'a pas la précision suffisante pour discriminer les deux objets. Pour lui, les deux objets sont à la même place. Conséquence : il faut bien choisir un des deux objets et ce choix se fait pixel par pixel, ce qui fait des artefacts visuels apparaissent. On parle alors de '''''z-fighting'''''. Voici ce que cela donne : [[File:Z-fighting.png|centre|vignette|Z-fighting]] Un défaut du tampon de profondeur est qu'il ne gère pas correctement les objets transparents. Dès que de la transparence est présente dans une scène 3D, le tampon de profondeur ne peut pas être utilisé. Une solution pour cela est de rendre une scène 3D en deux phases : une pour les objets opaques, une avec les objets transparents. La où on rend les objets opaques utilise le tampon de profondeur, mais il est désactivé lors de la seconde. ==La rastérisation et les textures== Dans cette section, nous allons voir ensemble l'étape de rastérisation et l'étape de traitement des pixels. La rastérisation est difficile à expliquer, surtout que son rôle exact dépend de la technique de rendu utilisée. Pour simplifier, elle projette un rendu en 3D sur un écran en 2D. Une autre explication tout aussi vague est qu'elle s'occupe la traduction des triangles en un affichage pixelisé à l'écran. Elle détermine à quoi ressemble la scène visible sur l'écran. C'est par exemple lors de cette étape que sont appliquées certaines techniques de ''culling'', qui éliminent les portions non-visibles de l'image, ainsi qu'une correction de la perspective et diverses opérations d'interpolation dont nous parlerons dans plusieurs chapitres. La rastérisation et placage de textures sont deux opérations très liées entre elles. Il existe deux manières principales pour lier les textures à la géométrie : la méthode directe et la méthode inverse (''UV Mapping''). Et les deux font que la rastérisation se fait de manière très différente. Précisons cependant que les rendus les plus simples n'utilisent pas de textures du tout. Ils se contentent de colorier les triangles, voire d'un simple rendu en fil de fer basé sur du tracé de lignes. Dans la suite de cette section, nous allons voir les quatre types de rendu principaux : le rendu en fils de fer, le rendu colorié, et deux rendus utilisant des textures. ===Le rendu en fil de fer=== [[File:Obj lineremoval.png|vignette|Rendu en fil de fer d'un objet 3D.]] Le '''rendu 3D en fils de fer''' est illustré ci-contre. Il s'agit d'un rendu assez ancien, utilisé au tout début de la 3D, sur des machines qu'on aurait du mal à appeler ordinateurs. Il se contente de tracer des lignes à l'écran, lignes qui connectent deux sommets, qui ne sont autres que les arêtes de la géométrie de la scène rendue. Le tout était suffisant pour réaliser quelques jeux vidéos rudimentaires. Les tout premiers jeux vidéos utilisaient ce rendu, l'un d'entre eux étant Maze War, le tout premier FPS. {| |[[File:Maze war.jpg|vignette|Maze war]] |[[File:Maze representation using wireframes 2022-01-10.gif|centre|vignette|Maze representation using wireframes 2022-01-10]] |} Le monde est calculé en 3D, il y a toujours un calcul de la géométrie, la scène est rastérisée normalement, les portions invisbles de l'image sont retirées, mais il n'y a pas d'application de textures après rastérisation. A la place, un algorithme de tracé de ligne trace les lignes à l'écran. Quand un triangle passe l'étape de rastérisation, l'étape de rastérisation fournit la position des trois sommets sur l'écran. En clair, elle fournit les coordonnées de trois pixels, un par sommet. A la suite, un algorithme de tracé de ligne trace trois lignes, une par paire de sommet. L'implémentation demande juste d'avoir une unité de calcul géométrique, une unité de rastérisation, et un VDC qui supporte le tracé de lignes. Elle est donc assez simple et ne demande pas de circuits de gestion des textures ni de ROP. Le VDC écrit directement dans le ''framebuffer'' les lignes à tracer. Il a existé des proto-cartes graphiques spécialisées dans ce genre de rendu, comme le '''''Line Drawing System-1''''' de l'entreprise Eans & Sutherland. Nous détaillerons son fonctionnement dans quelques chapitres. ===Le rendu à primitives colorées=== [[File:MiniFighter.png|vignette|upright=1|Exemple de rendu pouvant être obtenu avec des sommets colorés.]] Une amélioration du rendu précédent utilise des triangles/''quads'' coloriés. Chaque triangle ou ''quad'' est associé à une couleur, et cette couleur est dessinée sur le triangle/''quad''après la rastérisation. Le rendu est une amélioration du rendu en fils de fer. L'idée est que chaque triangle/''quad'' est associé à une couleur, qui est dessinée sur le triangle/''quad'' après la rastérisation. La technique est nommée ''colored vertices'' en anglais, nous parlerons de '''rendu à maillage coloré'''. [[File:Malla irregular de triángulos modelizando una superficie convexa.png|centre|vignette|upright=2|Maillage coloré.]] La couleur est propagée lors des calculs géométriques et de la rastérisation, sans subir de modifications. Une fois un rendu en fils de fer effectué, la couleur du triangle est récupérée. Le triangle/''quad'' rendu correspond à un triangle/''quad'' à l'écran. Et l'intérieur de ce triangle/''quad'' est colorié avec la couleur transmise. Pour cela, on utilise encore une fois une fonction du VDC : celle du remplissage de figure géométrique. Nous l’avions vu en parlant des VDC à accélération 2D, mais elle est souvent prise en charge par les ''blitters''. Ils peuvent remplir une figure géométrique avec une couleur unique, on réutilise cette fonction pour colorier le triangle/''quad''. L'étape de rastérisation fournit les coordonnées des sommets de la figure géométrique, le ''blitter'' les utilise pour colorier la figure géométrique. Niveau matériel, quelques bornes d'arcade ont utilisé ce rendu. La toute première borne d'arcade utilisant le rendu à maillage coloré est celle du jeu I Robot, d'Atari, sorti en 1983. Par la suite, dès 1988, les cartes d'arcades Namco System 21 et les bornes d'arcades Sega Model 1 utilisaient ce genre de rendu. On peut s'en rendre compte en regardant les graphismes des jeux tournant sur ces bornes d'arcade. Des jeux comme Virtua Racing, Virtua Fighter ou Virtua Formula sont assez parlants à ce niveau. Leurs graphismes sont assez anguleux et on voit qu'ils sont basés sur des triangles uniformément colorés. Pour ceux qui veulent en savoir plus sur la toute première borne d'arcade en rendu à maillage colorée, la borne ''I Robot'' d'Atari, voici une vidéo youtube à ce sujet : * [https://www.youtube.com/watch?v=6miEkPENsT0 I Robot d'Atari, le pionnier de la 3D Flat.] ===Le placage de textures direct=== Les deux rendus précédents sont très simples, mais n'utilisent pas de textures. Et il est temps de voir les deux rendus qui utilisent des textures. Il y en a deux types, appelés rendu avec placage de texture direct et indirect, nous allons voir le '''rendu par placage de texture direct''' en premier. Et nous l'appellerons ''rendu direct'' dans ce qui suit, pour simplifier les explications. L'idée est assez simple et peut utiliser aussi bien des triangles que des ''quads'', mais nous allons partir du principe qu'elle utilise des '''''quads''''', à savoir que les objets 3D sont composés de quadrilatères. Lorsqu'un ''quad'' est rastérisé, sa forme à l'écran est un rectangle déformé par la perspective. On obtient un rectangle si le ''quad'' est vu de face, un trapèze si on le voit de biais. Et le ''sprite'' doit être déformé de la même manière que le ''quad''. L'idée est que tout quad est associé à une texture, à un sprite. La figure géométrique qui correspond à un ''quad'' à l'écran est remplie non pas par une couleur uniforme, mais par un ''sprite'' rectangulaire. Il suffit techniquement de recopier le ''sprite'' à l'écran, c'est à dire dans la figure géométrique, au bon endroit dans le ''framebuffer''. Le rendu direct est en effet un intermédiaire entre rendu 2D à base de ''sprite'' et rendu 3D moderne. La géométrie est rendue en 3D pour générer des ''quads'', mais ces ''quads'' ne servent à guider la copie des sprites/textures dans le ''framebuffer''. [[File:TextureMapping.png|centre|vignette|upright=2|Exemple caricatural de placage de texture sur un ''quad''.]] La subtilité est que le sprite est déformé de manière à rentrer dans un quadrilatère, qui n'est pas forcément un rectangle à l'écran, mais est déformé par la perspective et son orientation en 3D. Le sprite doit être déformé de deux manières : il doit être agrandi/réduit en fonction de la taille de la figure affichée à l'écran, tourné en fonction de l'orientation du ''quad'', déformé pour gérer la perspective. Pour cela, il faut connaitre les coordonnées de profondeur de chaque bord d'un ''quad'', et de faire quelques calculs. N'importe quel VDC incluant un ''blitter'' avec une gestion du zoom/rotation des sprites peut le faire. : Si on veut avoir de beaux graphismes, il vaut mieux appliquer un filtre pour lisser le sprite envoyé dans le trapèze, filtre qui se résume à une opération d'interpolation et n'est pas très différent du filtrage de texture qui lisse les textures à l'écran. Un autre point est que les ''quads'' doivent être rendus du plus lointain au plus proche. Sans cela, on obtient rapidement des erreurs de rendu. L'idée est que si deux quads se chevauchent, on doit dessiner celui qui est derrière, puis celui qui est devant. Le dessin du second va recouvrir le premier. L'écriture du sprite du second quad écrasera les données du premier quad, pour les portions recouvertes, lors de l'écriture du sprite dans le ''framebuffer''. Quelque chose qui devrait vous rappeler le rendu 2D, où les sprites sont rendus du plus lointain au plus proche. Le rendu inverse utilise très souvent des triangles pour la géométrie, alors que le rendu direct a tendance à utiliser des ''quads'', mais il ne s'agit pas d'une différence stricte. L'usage de triangles/''quads'' peut se faire aussi bien avec un rendu direct comme avec un rendu inverse. Cependant, le rendu en ''quad'' se marie très bien au rendu direct, alors que le rendu en triangle colle mieux au rendu inverse. L'avantage de cette technique est qu'on parcourt les textures dans un ordre bien précis. Par exemple, on peut parcourir la texture ligne par ligne, l'exploiter par blocs de 4*4 pixels, etc. Et accéder à une texture de manière prédictible se marie bien avec l'usage de mémoires caches, ce qui est un avantage en matière de performances. Mais un même pixel du ''framebuffer'' est écrit plusieurs fois quand plusieurs quads se superposent, alors que le rendu inverse gère la situation avec une seule écriture (sauf si usage de la transparence). De plus, la gestion de la transparence était compliquée et les jeux devaient ruser en utilisation des solutions logicielles assez complexes. Niveau implémentation matérielle, une carte graphique en rendu direct demande juste trois circuits. Le premier est un circuit de calcul géométrique, qui rend la scène 3D. Le tri des quads est souvent réalisé par le processeur principal, et non pas par un circuit séparé. Toutes les étapes au-delà de l'étape de rastérisation étaient prises en charge par un VDC amélioré, qui écrivait des sprites/textures directement dans le ''framebuffer''. {|class="wikitable" |- ! Géométrie | Processeurs dédiés programmé pour émuler le pipeline graphique |- ! Tri des quads du plus lointain au plus proche | Processeur principal (implémentation logicielle) |- ! Application des textures | ''Blitter'' amélioré, capable de faire tourner et de zoomer sur des ''sprites''. |} L'implémentation était très simple et réutilisait des composants déjà existants : des VDC 2D pour l'application des textures, des processeurs dédiés pour la géométrie. Les unités de calcul de la géométrie étaient généralement implémentées avec un ou plusieurs processeurs dédiés. Vu qu'on savait déjà effectuer le rendu géométrique en logiciel, pas besoin de créer un circuit sur mesure. Il suffisait de dédier un processeur spécialisé rien que pour les calculs géométriques et on lui faisait exécuter un code déjà bien connu à la base. En clair, ils utilisaient un code spécifique pour émuler un circuit fixe. C'était clairement la solution la plus adaptée pour l'époque. Les unités géométriques étaient des processeurs RISC, normalement utilisés dans l'embarqué ou sur des serveurs. Elles utilisaient parfois des DSP. Pour rappel, les DSP des processeurs de traitement de signal assez communs, pas spécialement dédiés aux rendu 3D, mais spécialisé dans le traitement de signal audio, vidéo et autre. Ils avaient un jeu d'instruction assez proche de celui des cartes graphiques actuelles, et supportaient de nombreuses instructions utiles pour le rendu 3D. [[File:Sega ST-V Dynamite Deka PCB 20100324.jpg|vignette|Sega ST-V Dynamite Deka PCB 20100324]] Le rendu direct a été utilisé dans la période de transition entre rendu 2D et rendu 3D, car il était très adapté pour faire cette transition. Coupler un VDC à un processeur pour la géométrie était particulièrement simple à l'époque. Le rendu direct a été utilisé sur des bornes d'arcade dès les années 90. Outre les bornes d'arcade, quelques consoles de 5ème génération utilisaient le rendu direct, avec les mêmes solutions matérielles. La géométrie était calculée sur plusieurs processeurs dédiés. Le reste du pipeline était géré par un VDC 2D qui implémentait le placage de textures. Deux consoles étaient dans ce cas : la 3DO, et la Sega Saturn. Le rendu direct est aujourd'hui abandonné. ===Le placage de textures inverse=== Le rendu précédent, le rendu direct, permet d'appliquer des textures directement dans le ''framebuffer''. Mais comme dit plus haut, il existe une seconde technique pour plaquer des textures, appelé le '''placage de texture inverse''', aussi appelé l'''UV Mapping''. Elle associe une texture complète pour un modèle 3D,contrairement au placage de tecture direct qui associe une texture par ''quad''/triangle. L'idée est que l'on attribue un texel à chaque sommet. Plus précisémment, chaque sommet est associé à des '''coordonnées de texture''', qui précisent quelle texture appliquer, mais aussi où se situe le texel à appliquer dans la texture. Par exemple, la coordonnée de texture peut dire : je veux le pixel qui est à ligne 5, colonne 27 dans cette texture. La correspondance entre texture et géométrie est réalisée lorsque les créateurs de jeu vidéo conçoivent le modèle de l'objet. [[File:Texture Mapping example.png|centre|vignette|upright=2|Exemple de placage de texture.]] Dans les faits, on n'utilise pas de coordonnées entières de ce type, mais deux nombres flottants compris entre 0 et 1. La coordonnée 0,0 correspond au texel en bas à gauche, celui de coordonnée 1,1 est tout en haut à droite. L'avantage est que ces coordonnées sont indépendantes de la résolution de la texture, ce qui aura des avantages pour certaines techniques de rendu, comme le ''mip-mapping''. Les deux coordonnées de texture sont notées u,v avec DirectX, ou encore s,t dans le cas général : u est la coordonnée horizontale, v la verticale. [[File:UVMapping.png|centre|vignette|upright=2|UV Mapping]] Avec le placage de texture inverse, la rastérisation se fait grosso-modo en trois étapes : la rastérisation proprement dite, le placage de textures, et les opérations finales qui écrivent un pixel dans le ''framebuffer''. Au niveau du matériel, ainsi que dans la plupart des API 3D, les trois étapes sont réalisées par des circuits séparés. [[File:01 3D-Rasterung-a.svg|vignette|Illustration du principe de la rasterization. La surface correspondant à l'écran est subdivisée en pixels carrés, de coordonnées x et y. La caméra est placée au point e. Pour chaque pixel, on trace une droite qui part de la caméra et qui passe par le pixel considéré. L'intersection entre une surface et cette droite se fait en un point, appartenant à un triangle.]] Lors de la rasterisation, chaque triangle se voit attribuer un ou plusieurs pixels à l'écran. Pour bien comprendre, imaginez une ligne droite qui part de caméra et qui passe par un pixel sur le plan de l'écran. Cette ligne intersecte 0, 1 ou plusieurs objets dans la scène 3D. Les triangles situés ces intersections entre cette ligne et les objets rencontrés seront associés au pixel correspondant. L'étape de rastérisation prend en entrée un triangle et renvoie la coordonnée x,y du pixel associé. Il s'agit là d'une simplification, car un triangle tend à occuper plusieurs pixels sur l'écran. L'étape de rastérisation fournit la liste de tous les pixels occupés par un triangle, et les traite un par un. Quand un triangle est rastérisé, le rasteriseur détermine la coordonnée x,y du premier pixel, applique une texture dessus, puis passe au suivant, et rebelote jusqu'à ce que tous les pixels occupés par le triangles aient été traités. L'implémentation matérielle du placage de texture inverse est beaucoup plus complexe que pour les autres techniques. Pour être franc, nous allons passer le reste du cours à parler de l'implémentation matérielle du placage de texture inverse, ce qui prendra plus d'une dizaine de chapitres. ==La transparence, les fragments et les ROPs== Dans ce qui suit, nous allons parler uniquement de la rastérisation avec placage de textures inverse. Les autres formes de rastérisation ne seront pas abordées. La raison est que tous les GPUs modernes utilisent cette forme de rastérisation, les exceptions étant rares. De même, ils utilisent un tampon de profondeur, pour l'élimination des surfaces cachées. La rastérisation effectue donc des calculs géométriques, suivis d'une étape de rastérisation, puis de placage des textures. Ces trois étapes sont réalisées par une unité géométrique, une unité de rastérisation, et un circuit de placage de textures. Du moins sur le principe, car les cartes graphiques modernes ont fortement optimisé l'implémentation et n'ont pas hésité à fusionner certains circuits. Mais nous verrons cela en temps voulu, nous n'allons pas résumer plusieurs décennies d'innovation technologique en quelques paragraphes. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Placage de textures |} Mais où mettre le tampon de profondeur ? Intuitivement, on se dit qu'il vaut mieux faire l'élimination des surfaces cachées le plus tôt possible, dès que la coordonnée de profondeur est connue. Et elle est connu à l'étape de rastérisation, une fois les sommets transformés. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Tampon de profondeur | Placage de textures |} En réalité, la profondeur des fragments est gérée par un circuit appelé le '''''Raster Operations Pipeline''''' (ROP), situé à la toute fin du pipeline graphique. Dans ce qui suit, nous utiliserons l'abréviation ROP pour simplifier les explications. Le ROP effectue quelques traitements sur les fragments, avant d'enregistrer l'image finale dans la mémoire vidéo. Il est placé à la fin du pipeline pour gérer correctement la transparence. Et nous allons voir pourquoi la transparence est gérée à la fin du pipeline. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Placage de textures | ''Raster Operations Pipeline'' |} ===Le mélange ''alpha''=== La transparence se manifeste quand plusieurs objets sont l'un derrière l'autre. Histoire de simplifier les explications, nous allons d'abord voir le cas où un objet semi-transparent est devant un objet opaque. La couleur perçue est alors un mélange de la couleur de l'objet opaque et celle de l'objet semi-transparent. Le mélange dépend d'à quel point l'objet semi-transparent est transparent. Avec un objet parfaitement transparent, seul l'objet opaque est visible. Avec un objet à moitié transparent, la couleur finale sera pour moitié celle de l'objet opaque, pour moitié celle de l'objet semi-transparent. Et c'est pareil pour les cas intermédiaires entre un objet totalement transparent et un objet totalement opaque. La transparence d'un objet/pixel est définie par un nombre, appelé la '''composante ''alpha'''''. Plus la composante alpha est élevée, plus le pixel est opaque. Elle vaut 0 pour un objet opaque et 1 pour un objet transparent. Elle est ajoutée aux composantes RGB, ce qui fait que tout fragment contient une "couleur de transparence" en plus des couleurs RGB. Elle agit comme un coefficient qui dit comment mélanger la couleur d'un objet transparent et d'un objet opaque. Le calcul de la transparence est une moyenne pondérée par la composante alpha. On parle alors d''''''alpha blending'''''. : <math>\text{Couleur finale} = \alpha \times \text{Couleur de l'objet transparent} + (1 - \alpha) \times \text{Couleur de l'objet opaque}</math> [[File:Texture splatting.png|centre|vignette|upright=2.0|Calcul de transparence. La première ligne montre le produit pour l'objet transparent, la seconde ligne est celle de l'objet opaque. La troisième ligne est celle de l'addition finale.]] Maintenant, qu'en est-il du cas où plusieurs objets sont superposés ? Si vous tracez une demi-droite dont l'origine est la caméra et qui passe par le pixel, il arrive qu'elle intersecte la géométrie en plusieurs points, un point par objet sur la ligne du regarde. Sans transparence, l'objet le plus proche cache tous les autres et c'est donc lui qui décide de la couleur du pixel. Mais avec un objet transparent, la couleur finale est un mélange de la couleur de plusieurs points d'intersection. Il faut donc calculer un pseudo-pixel pour chaque point d'intersection, auquel on donne le nom de '''fragment'''. Un fragment possède une position à l'écran, une coordonnée de profondeur, une couleur, ainsi que quelques autres informations potentiellement utiles. Les fragments attribués à un même pixel, qui sont à la même position sur l'écran, sont combinés pour obtenir la couleur finale de ce pixel. Il est possible d'utiliser le mélange ''alpha'' pour cela. Il suffit de faire le mélange ''alpha'' entre le fragment qui vient d'être calculé, et le pixel dans le ''framebuffer''. Pour cela, le fragment a une composante ''alpha'', qui est ajouté aux trois couleurs RGB. Le pixel déjà dans le ''framebuffer'' est un résultat temporaire, né du mélange ''alpha'' de tous les fragments précédents. Un défaut de cette méthode est qu'elle fonctionne assez mal avec un tampon de profondeur. Si le tampon de profondeur est activé, le mélange ''alpha'' ne fonctionne que si les objets sont rendus du plus lointain au plus proche. Et procéder dans cet ordre a un défaut : on dessine des objets dans le ''framebuffer'', pour qu'ensuite les objets devant écrasent ce qui a déjà été dessiné. Un même pixel peut donc être dessiné plusieurs fois, dont une seule sera pertinente. Et ces écritures utilisent de la bande passante mémoire, qui est une ressource précieuse sur un GPU moderne. Il s'agit d'un phénomène appelé '''''overdraw''''', ou sur-dessinage en français. Quelques optimisations permettent d'éliminer l'''overdraw'' en rendant les objets du plus proche au plus lointain, d'autres permettent de dessiner des objets dans un ordre arbitraire, mais nous ne pouvons pas en parler ici. Beaucoup de moteurs 3D rendent séparément les objets opaques et transparents. Une première passe rend les objets opaques, puis les objets transparents sont rendus dans une seconde passe. Les objets opaques sont rendus dans le désordre, ce qui fait qu'on n'a pas à les trier, alors que les objets transparents doivent être triés selon leur distance. un autre avantage est que le mélange ''alpha'' est désactivé lors de la première passe, alors que c'est la mise à jour du tampon de profondeur qui est désactivé lors de la seconde passe, ce qui augmente un peu les performances dans les deux cas. ===Le test ''alpha''=== Le test ''alpha'' est une technique qui permet d'annuler le rendu d'un fragment en fonction de sa transparence. Si la composante alpha est en-dessous ou au-dessus d'un seuil, le fragment est simplement abandonné. Le seuil en question est configurable, de même que la comparaison utilisée : on peut éliminer le fragment si sa transparence est au-dessus d'un certain seuil, en-dessous, égal, différent, etc. Il s'agit d'une optimisation qui est utile dans certains scénarios spécifiques. Par exemple, si l'objet a une transparence très élevée, du genre 95%, autant le compter comme complétement transparent, afin d'éviter des opérations de mélange ''alpha''. En effet, les opérations de mélange ''alpha'' sont très lentes, car elles demandent de faire des opérations de lecture-écriture en mémoire vidéo : on lit un pixel dans le ''framebuffer'', on applique le mélange ''alpha'' et on écrit le résultat en mémoire vidéo. L'''alpha test'' permet donc de gagner en performance au prix d'une baisse de la qualité d'image. Il y a cependant des cas où l'usage du test ''alpha'' est primordial, au-delà d'une question de performances. Un exemple classique est celui du rendu du feuillage dans un jeu 3D. Un feuillage est composé en assemblant plusieurs images de feuilles. Chaque feuille est un carré sur lequel on place une texture de feuille, qui est opaque pour la partie verte des feuilles, transparente pour le reste. Les carrés ne sont cependant pas superposés, mais s'intersectent fortement, ce qui fait que le mélange ''alpha'' ne donne pas de bons résultats. L'usage du test ''alpha'' permet d'obtenir un rendu correct. Pour d'informations via ce lien : * [https://bgolus.medium.com/anti-aliased-alpha-test-the-esoteric-alpha-to-coverage-8b177335ae4f Anti-aliased Alpha Test: The Esoteric Alpha To Coverage]. ===Les effets de brouillard=== Les '''effets de brouillard''' sont nécessaires dans certains jeux vidéo pour l'ambiance (pensez à des jeux d'horreur comme Silent Hill), mais ils ont surtout été utilisés pour économiser des calculs. L'idée est de ne pas calculer les graphismes au-delà d'une certaine distance, sans que cela se voie. Le ''view frustum'' utilise alors un plan limite, au-delà duquel on ne voit pas les objets. Mais ce plan limite donne une cassure inesthétique dans le rendu. Pour masquer cette cassure, les programmeurs ajoutaient un effet de brouillard. Les objets au-delà du plan limite étaient totalement dans le brouillard, puis ce brouillard se réduisait progressivement en se rapprochant de la caméra, avant de s'annuler à partir d'une certaine distance. Pour calculer le brouillard, on effectue un mélange ''alpha'' entre la couleur du pixel et une ''couleur de brouillard''. La différence est que l'on n'utilise pas la transparence pour faire le mélange, mais un '''coefficient de brouillard''', noté <math>\text{fog}(z)</math>. : <math>\text{Couleur finale} = \text{fog}(z) \times \text{Couleur de brouillard} + [ 1 - \text{fog}(z) ] \times \text{Couleur du pixel}</math> Le coefficient de brouillard dépend de la coordonnée de profondeur, de la distance du pixel par rapport à la caméra. Le brouillard démarre à une distance <math>z_{fog-start}</math>, et masque totalement les objets à partir d'une distance <math>z_{fog-end}</math>. Entre les deux, le coefficient de brouillard dépend de la distance. OpenGL autorise trois formules de calcul suivantes : : <math>\text{fog}(z) = \frac{z_{fog-end} - z}{z_{fog-end} - z_{fog-start}}</math> : <math>\text{fog}(z) = e^{- k \times z}</math> : <math>\text{fog}(z) = e^{- (k \times z)^2}</math> ==L'éclairage d'une scène 3D== L'éclairage d'une scène 3D calcule les ombres, mais aussi la luminosité de chaque pixel, ainsi que bien d'autres effets graphiques. Les algorithmes d'éclairage ont longtemps été implémentés directement en matériel, les cartes graphiques géraient l'éclairage dans des circuits spécialisés. Aussi, il est important de voir ces algorithmes d'éclairage. Il est possible d'implémenter l'éclairage à deux endroits différents du pipeline : juste avant la rastérisation, et après la rastérisation. ===Les sources de lumière et les couleurs associées=== L'éclairage d'une scène 3D provient de sources de lumières, comme des lampes, des torches, le soleil, etc. Il existe de nombreux types de sources de lumière, et nous n'allons parler que des principales. Elles sont au nombre de quatre et elles sont illustrées ci-dessous. [[File:3udUJ.gif|centre|vignette|upright=2|Types de sources de lumière.]] [[File:Graphics lightmodel directional.png|vignette|upright=1.0|Source de lumière directionnelle.]] Les '''sources directionnelles''' servent à modéliser des sources de lumière très éloignées, comme le soleil ou la lune. Elles sont simplement définies par un vecteur qui indique la direction de la lumière, rien de plus. Les '''sources ponctuelles''' sont des points, qui émettent de la lumière dans toutes les directions. Elles sont définies par une position, et une intensité lumineuse, éventuellement la couleur de la lumière émise. Il existe deyux types de sources de lumière ponctuelles. * Le premières émettent de manière égale dans toutes les directions. Elles sont appelées des ''point light'' dans le schéma du dessus. * Les secondes émettent de la lumière dans une '''direction privilégiée'''. L'exemple le plus parlant est celui d'une lampe-torche : elle émet de la lumière "tout droit", dans la direction où la lampe est orientée. Elles sont appelées des ''sport light'' dans le schéma du dessus. La direction privilégiée est un vecteur, notée v dans le schéma du dessous. [[File:Graphics lightmodel ambient.png|vignette|upright=1.0|Lumière ambiante.]] En théorie, la lumière rebondit sur les surfaces et a tendance à se disperser un peu partout à force de rebondir. C'est ce qui explique qu'on arrive à voir à l'intérieur d'une pièce si une fenêtre est ouverte. Il en résulte un certain '''éclairage ambiant''', qui est assez difficile à représenter dans un moteur de rendu 3D. Auparavant, l'éclairage ambiant était simulé par une lumière égale en tout point de la scène 3D, appelée simplement la '''lumière ambiante'''. Précisément, on suppose que la lumière ambiante en un point vient de toutes les directions et a une intensité constante, identique dans toutes les directions. Le tout est illustré ci-contre. C'est assez irréaliste, mais ça donne une bonne approximation de la lumière ambiante. ===La lumière incidente : le terme géométrique=== Pour simplifier, nous allons supposer que l'éclairage est calculé pour chaque sommet, pas par triangle. C'est de loin le cas le plus courant, aussi ce n'est pas une simplification abusive. La lumière qui arrive sur un sommet est appelée la '''lumière incidente'''. La couleur d'un sommet dépend de deux choses : la lumière incidente directe, comment il réfléchit cette lumière. Mathématiquement, il est possible de résumer cela avec le produit de deux termes : l'intensité de la lumière incidente, une fonction qui indique comment la surface réfléchit la lumière incidente. La fonction en question est appelée la '''réflectivité bidirectionnelle'''. Le terme anglais est ''bidirectional reflectance distribution function'', abrévié en BRDF, et nous utiliserons cette abréviation dans ce qui suit. : <math>\text{Couleur finale} = \text{Lumière incidente} \times BRDF(...)</math> La lumière incidente vient soit directement des sources de lumière, soit de la lumière qui a rebondit sur d'autres objets proches. La première est appelée la lumière directe, celle qui vient des rebonds s'appelle la lumière indirecte. Pour simplifier, la lumière indirecte est gérée par la lumière ambiante, nous passons sous silence les techniques d'illumination globale. En clair : nous allons nous limiter au cas où la lumière incidente vient directement d'une source de lumière, pas d'un rebond. Intuitivement, la lumière incidente est simplement égale à l'intensité de la source de lumière. Sauf que ce n'est qu'une approximation, et une assez mauvaise. En réalité, l'approximation est bonne si la lumière arrive proche de la verticale, mais elle est d'autant plus mauvaise que la lumière arrive penchée, voire rasante. La raison : la lumière incidente sera étalée sur une surface plus grande, si elle arrive penchée. Si vous vous souvenez de vos cours de collège, c'est le même principe qui explique les saisons. La lumière du soleil est proche de la verticale en été, mais est de plus en plus penché quand on s'avance vers l'Hiver. La lumière solaire est donc étalée sur une surface plus grande, ce qui fait qu'un point de la surface recevra moins de lumière, celle-ci étant diluée, étalée. [[File:Radiación solar.png|centre|vignette|upright=2|Exemple avec la lumière solaire.]] [[File:Angle of incidence.svg|vignette|upright=1|Angle d'incidence.]] En clair, tout dépend de l''''angle d'incidence''' de la lumière. Reste à voir comment calculer cet angle. La lumière incidente est définie par un vecteur, qui part de la source de lumière et atterrit sur le sommet considéré. Imaginez simplement que ce vecteur suit un rayon lumineux provenant de la source de lumière. Le vecteur pour la lumière incidente sera noté L. L'angle d'incidence est l'angle que fait ce vecteur avec la verticale de la surface, au niveau du sommet considéré. [[File:Graphics lightmodel ptsource.png|vignette|Normale de la surface.]] Pour cela, les calculs d'éclairage ont besoin de connaitre la verticale d'un sommet. Un sommet est donc associé à un vecteur, appelé la '''normale''', qui indique la verticale en ce point. Deux sommets différents peuvent avoir deux normales différentes, même s'ils sont proches. Elles sont d'autant plus différentes que la surface est rugueuse, non-lisse. La normale est prédéterminée lors de la création du modèle 3D, il n'y a pas besoin de le calculer. Par contre, elle est modifiée lors de l'étape de transformation, quand on place le modèle 3D dans la scène 3D. Les deux autres vecteurs sont à calculer à chaque image, car ils changent quand on bouge le sommet. La lumière qui arrive sur la surface dépend de l'angle entre la normale et le vecteur L. Précisément, elle dépend du cosinus de cet angle. En multipliant ce cosinus avec l'intensité de la lumière, on a la lumière arrivante. La couleur finale d'un pixel est donc : : <math>\text{Couleur finale} = I \times \cos{(N, L)} \times BRDF(...)</math> Le terme <math>I \times \cos{N, L}</math> ne dépend pas de la surface considérée. Juste de la position de la source de lumière, de la position du sommet et de son orientation par rapport à la lumière. Aussi, il est parfois appelé le '''terme géométrique''', en opposition aux propriétés de la surface. Les propriétés de la surface sont définies par un '''''material''''', qui indique comment il réfléchit la lumière, ainsi que sa texture. ===Le produit scalaire de deux vecteurs=== Calculer le terme géométrique demande de calculer le cosinus d'un angle. Et il n'est pas le seul : les autres calculs d'éclairage que nous allons voir demandent de calculer des cosinus. Or, les calculs trigonométriques sont très gourmands pour le GPU. Pour éviter le calcul d'un cosinus, les GPU utilisent une opération mathématique appelée le ''produit scalaire''. Le produit scalaire agit sur deux vecteurs, que l'on notera A et B. Un produit scalaire prend : la longueur des deux vecteurs, et l'angle entre les deux vecteurs noté <math>\omega</math>. Le produit scalaire est équivalent à la formule suivante : : <math>\text{Produit scalaire de deux vecteurs A et B} = \vec{A} \cdot \vec{B} = A \times B \times \cos{(\omega)}</math>, avec A et B la longueur des deux vecteurs A et B. L'avantage est que le produit scalaire se calcule simplement avec des additions, soustractions et multiplications, des opérations que les cartes graphiques savent faire très facilement. Le produit scalaire de deux vecteurs de coordonnées x,y,z est le suivant : : <math>\vec{A} \cdot \vec{B} = x_A \times x_B + y_A \times y_B + z_A \times z_B</math> En clair, on multiplie les coordonnées identiques, et on additionne les résultats. Rien de compliqué. Un avantage est que tous les vecteurs vus précédemment sont normalisés, à savoir qu'ils ont une longueur qui vaut 1. Ainsi, le calcul du produit scalaire devient équivalent au calcul du produit scalaire. ===La réflexion de la lumière sur la surface=== [[File:Ray Diagram 2.svg|vignette|Reflection de la lumière sur une surface parfaitement lisse.]] Maintenant que nous venons de voir le terme géométrique, voyons le BRDF, qui définit comment la surface de l'objet 3D réfléchit la lumière. Vos cours de collège vous ont sans doute appris que la lumière est réfléchie avec le même angle d'arrivée. L'angle d'incidence et l'angle de réflexion sont égaux, comme illustré ci-contre. On parle alors de '''réflexion parfaite'''. Mais cela ne vaut que pour une surface parfaitement lisse, comme un miroir parfait. Dans la réalité, une surface a tendance à renvoyer des rayons dans toutes les directions. La raison est qu'une surface réelle est rugueuse, avec de petites aspérités et des micro-reliefs, qui renvoient la lumière dans des directions "aléatoires". La lumière « rebondit » sur la surface de l'objet et une partie s'éparpille dans un peu toutes les directions. On parle alors de '''réflexion diffuse'''. {| |- |[[File:Dioptre reflexion diffuse speculaire refraction.svg|vignette|upright=1.4|Différence entre réflexion diffuse et spéculaire.]] |[[File:Diffuse reflection.svg|vignette|upright=1|Réflexion diffuse.]] |} Maintenant, imaginons que la surface n'ait qu'une réflexion diffuse, pas d'autres formes de réflexion. Et imaginons aussi que cette réflexion diffuse soit parfaite, à savoir que la lumière réfléchie soit renvoyée à l'identique dans toutes les directions, sans aucune direction privilégiée. On a alors le ''material'' le plus simple qui soit, appelé un '''''diffuse material'''''. Vu que la lumière est réfléchie à l'identique dans toutes les directions, elle sera identique peu importe où on place la caméra. La lumière finale ne dépend donc que des propriété de la surface, que de sa couleur. En clair, il suffit de donner une '''couleur diffuse''' à chaque sommet. La couleur diffuse est simplement multipliée par le terme géométrique, pour obtenir la lumière réfléchie finale. Rien de plus, rien de moins. Cela donne l'équation suivante, avec les termes suivants : * L est le vecteur pour la lumière incidente ; * N est la normale du sommet ; * I est l'intensité de la source de lumière ; * <math>C_d</math> est la couleur diffuse. : <math>\text{Illumination diffuse} = C_d \times \left[ I \times (\vec{N} \cdot \vec{L}) \right]</math> Rajoutons maintenant l'effet de la lumière ambiante à un ''material'' de ce genre. Pour rappel, la lumière ambiante vient de toutes les directions à part égale, ce qui fait que son angle d'incidence n'a donc pas d'effet. L'intensité de la lumière ambiante est déterminée lors de la création de la scène 3D, c'est une constante qui n'a pas à être calculée. Pour obtenir l'effet de la lumière ambiante sur un objet, il suffit de multiplier sa couleur diffuse par l'intensité de la lumière ambiante. Cependant, de nombreux moteurs de jeux ajoutent une '''couleur ambiante''', différente de la couleur diffuse. : <math>\text{Illumination ambiante} = C_a \times I_a</math> avec <math>C_a</math> la couleur ambiante du point de surface et <math>I_a</math> l'intensité de la lumière ambiante. En plus de la réflexion diffuse parfaite, de nombreux matériaux ajoutent une '''réflexion spéculaire''', qui n'est pas exactement la réflexion parfaite, en est très proche. Les rayons réfléchis sont très proches de la direction de réflexion parfaite, et s'atténuent très vite en s'en éloignant. Le résultat ressemble à une sorte de petit "point blanc", très lumineux, orienté vers la source de lumière, appelé le '''''specular highlight'''''. La réflexion diffuse est prédominante pour les matériaux rugueux, alors que la réflexion spéculaire est dominante sur les matériaux métalliques ou très lisses. [[File:Phong components version 4.png|centre|vignette|upright=3.0|Couleurs utilisées dans l'algorithme de Phong.]] [[File:Phong Vectors.svg|vignette|Vecteurs utilisés dans l'algorithme de Phong (et dans le calcul de l'éclairage, de manière générale).]] Pour calculer la réflexion spéculaire, il faut d'abord connaitre le vecteur pour la réflexion parfaite, que nous noterons R dans ce qui suit. Le vecteur R peut se calculer avec la formule ci-dessous : : <math>\vec{R} = 2 (\vec{L} \cdot \vec{N}) \times \vec{N} - \vec{L} </math> La réflexion spéculaire dépend de l'angle entre la direction du regard et la normale : plus celui-ci est proche de l'angle de réflexion parfaite, plus la réflexion spéculaire sera intense. Le vecteur pour la direction du regard sera noté V, pour vue ou vision. La réflexion spéculaire est une fonction qui dépend de l'angle entre les vecteurs R et V. Le calcul de la réflexion spéculaire utilise une '''couleur spéculaire''', qui est l'équivalent de la couleur diffuse pour la réflexion spéculaire. : <math>\text{BRDF spéculaire} = C_s \times f(\vec{R} \cdot \vec{V}) </math> La fonction varie grandement d'un modèle de calcul spéculaire à l'autre. Aussi, je ne rentre pas dans le détail. L'essentiel est que vous compreniez que le calcul de l'éclairage utilise de nombreux calculs géométriques, réalisés avec des produits scalaires. Les calculs géométriques utilisent la couleur d'un sommet, la normale du sommet, et le vecteur de la lumière incidente. Les autres informations sont calculées à l'exécution. ===Les algorithmes d'éclairage basiques : par triangle, par sommet et par pixel=== Dans tout ce qui a été dit précédemment, l'éclairage est calculé pour chaque sommet. Il attribue une illumination/couleur à chaque sommet de la scène 3D, ce qui fait qu'on parle d''''éclairage par sommet''', ou ''vertex lighting''. Il est assez rudimentaire et donne un éclairage très brut, mais il peut être réalisé avant l'étape de rastérisation. Mais une fois qu'on a obtenu la couleur des sommets, reste à colorier les triangles. Et pour cela, il y a deux manières de faire, qui sont appelées l'éclairage plat et l'éclairage de Gouraud. [[File:D3D Shading Triangles.png|vignette|Dans ce dessin, le triangle a un sommet de couleur bleu foncé, un autre de couleur rouge et un autre de couleur bleu clair. L’interpolation plate et de Gouraud donnent des résultats bien différents.]] L''''éclairage plat''' calcule l'éclairage triangle par triangle. Il y a plusieurs manières de faire pour ça, mais la plus simple colorie un triangle avec la couleur moyenne des trois sommets. Une autre possibilité fait les calculs d'éclairage triangle par triangle, en utilisant une normale par triangle et non par sommet, idem pour les couleurs ambiante/spéculaire/diffuse. Mais c'est plus rare car cela demande de placer la normale quelque part dans le triangle, ce qui rajoute des informations. L''''éclairage de Gouraud''' effectue lui aussi une moyenne de la couleur de chaque sommet, sauf que celle-ci est pondérée par la distance du sommet avec le pixel. Plus le pixel est loin d'un sommet, plus son coefficient est petit. Typiquement, le coefficient varie entre 0 et 1 : de 1 si le pixel est sur le sommet, à 0 si le pixel est sur un des sommets adjacents. La moyenne effectuée est généralement une interpolation bilinéaire, mais n'importe quel algorithme d'interpolation peut marcher, qu'il soit simplement linéaire, bilinéaire, cubique, hyperbolique. L'étape d'interpolation est prise en charge par l'étape de rastérisation, qui effectue cette moyenne automatiquement. L'éclairage par sommet a eu son heure de gloire, mais il est maintenant remplacé par l''''éclairage par pixel''' (''per-pixel lighting''), qui calcule l'éclairage pixel par pixel. En clair, l’éclairage est finalisé après l'étape de rastérisation, il ne se fait pas qu'au niveau de la géométrie. Il existe plusieurs types d'éclairage par pixel, mais on peut les classer en deux grands types : l'éclairage de Phong et le ''bump/normal mapping''. L''''éclairage de Phong''' calcule l'éclairage pixel par pixel. Avec cet algorithme, la géométrie n'est pas éclairée : les couleurs des sommets ne sont pas calculées. A la place, les normales sont envoyées à l'étape de rastérisation, qui effectue une opération d'interpolation, qui renvoie une normale pour chaque pixel. Les calculs d'éclairage utilisent alors ces normales pour faire les calculs d'éclairage pour chaque pixel. La technique du '''''normal mapping''''' est assez simple à expliquer, sans compter que plusieurs cartes graphiques l'ont implémentée directement dans leurs circuits. Là où l'éclairage de Phong interpole les normales pour chaque pixel, le ''normal-mapping'' précalcule les normales d'une surface dans une texture, appelée la ''normal-map''. Lors des calculs d'éclairage, la carte graphique lit les normales adéquates directement depuis cette texture, puis fait les calculs d'éclairage avec. [[File:WallSimpleAndNormalMapping.png|centre|vignette|upright=2|Différence sans et avec ''normal-mapping''.]] Avec cette technique, l'éclairage n'est pas géré par pixel, mais par texel, ce qui fait qu'il a une qualité de rendu un peu inférieure à un vrai éclairage de Phong, mais bien supérieure à un éclairage par sommet. Par contre, les techniques de ''normal mapping'' permettent d'ajouter du relief et des détails sur des surfaces planes en jouant sur l'éclairage. Elles permettent ainsi de simplifier grandement la géométrie rendue, tout en utilisant l'éclairage pour compenser. [[File:Bump mapping.png|centre|vignette|upright=2|Bump mapping]] L'éclairage par pixel a une qualité d'éclairage supérieure aux techniques d'éclairage par sommet, mais il est aussi plus gourmand. L'éclairage par pixel est utilisé dans presque tous les jeux vidéo depuis DOOM 3, en raison de sa meilleure qualité, mais cela n'aurait pas été possible si le matériel n'avait pas évolué de manière à incorporer des algorithmes d'éclairage matériel assez puissants, avant de basculer sur un éclairage programmable. La différence entre l'éclairage par pixel et par sommet se voit assez facilement à l'écran. L'éclairage plat donne un éclairage assez carré, avec des frontières assez nettes. L'éclairage de Gouraud donne des ombres plus lisses, dans une certaine mesure, mais pèche à rendre correctement les reflets spéculaires. L'éclairage de Phong est de meilleure qualité, surtout pour les reflets spéculaires. es trois algorithmes peuvent être implémentés soit dans la carte graphique, soit en logiciel. Nous verrons comment les cartes graphiques peuvent implémenter ces algorithmes, dans les deux prochains chapitres. {| |- |[[File:Per face lighting.png|vignette|upright=1|Flat shading]] |[[File:Per vertex lighting.png|vignette|upright=1|Gouraud Shading]] |[[File:Per fragment lighting.png|vignette|upright=1|Phong Shading]] |- |[[File:Per face lighting example.png|vignette|upright=1|Flat shading]] |[[File:Per vertex lighting example.png|vignette|upright=1|Gouraud Shading]] |[[File:Per fragment lighting example.png|vignette|upright=1|Phong Shading]] |} ===Les ''shaders'' : des programmes exécutés sur le GPU=== Maintenant que nous venons de voir les algorithmes d'éclairages, il est temps de voir comment les réaliser sur une carte graphique. Nous venons de voir qu'il y a une différence entre l'éclairage par pixel et par sommet. Intuitivement, l'éclairage par sommet devrait se faire avec les calculs géométriques, alors que l'éclairage par pixel devrait se faire après avoir appliqué les textures. Les toutes premières cartes graphiques ne géraient ni l'éclairage par sommet, ni l'éclairage par pixel. Elles laissaient les calculs géométriques au CPU. Par la suite, la Geforce 256 a intégré '''circuit de ''Transform & Lightning''''', qui s'occupait de tous les calculs géométriques, éclairage par sommet inclus (d'où le L de T&L). Elle gérait alors l'éclairage par sommet, mais un algorithme particulier, qui n'était pas très flexible. Il ne gérait que des ''material'' bien précis (des ''Phong materials''), rien de plus. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Unité de T&L : géométrie | Rastérisation | Placage de textures | ''Raster Operations Pipeline'' |} L'amélioration suivante est venue sur la Geforce 3 : l'unité de T&L est devenue programmable. Au vu le grand nombre d'algorithmes d'éclairages possibles et le grand nombre de ''materials'' possibles, c'était la seule voie possibles. Les programmeurs pouvaient programmer leurs propres algorithmes d'éclairage par sommet, même s'ils devaient aussi programmer les étapes de transformation et de projection. Mais nous détaillerons cela dans un chapitre dédié sur l'historique des GPUs. Ce qui est important est que la Geforce 3 a introduit une fonctionnalité absolument cruciale pour le rendu 3D moderne : les '''''shaders'''''. Il s'agit de programmes informatiques exécutés par la carte graphique, qui servaient initialement à coder des algorithmes d'éclairage. D'où leur nom : ''shader'' pour ''shading'' (éclairage en anglais). Cependant, l'usage modernes des shaders dépasse le cadre des algorithmes d'éclairage. L'avantage est que cela simplifie grandement l'implémentation des algorithmes d'éclairage. Pas besoin de les intégrer dans la carte graphique pour les utiliser, pas besoin d'un circuit distinct pour chaque algorithme. Sans shaders, si la carte graphique ne gère pas un algorithme d'éclairage, on ne peut pas l'utiliser. A la rigueur, il est parfois possible de l'émuler avec des contournements logiciels, mais au prix de performances souvent désastreuses. Avec des shaders, il est possible de programmer l'algorithme d'éclairage de notre choix, pour l'exécuter sur la carte graphique, avec des performances plus que convenables. [[File:Implémentation de l'éclairage sur les cartes graphiques.png|vignette|Implémentation de l'éclairage sur les cartes graphiques]] Il existe plusieurs types de shaders, mais les deux principaux sont les '''''vertex shaders''''' et les '''''pixel shaders'''''. Les pixels shaders s'occupent de l'éclairage par pixel, leur nom est assez parlent. Les vertex shaders s'occupent de l'éclairage par sommet, mais aussi des étapes de transformation/projection. Je parle bien des trois étapes de transformation vues plus haut, qui effectuent des calculs de transformation de coordonnées avec des matrices. La raison à cela est que les calculs de transformation ressemblent beaucoup aux calculs d'éclairage par sommet. Ils impliquent tous deux des calculs vectoriels, comme des produits scalaires et des produits vectoriels, qui agissent sur des sommets/triangles. Si la carte graphique incorpore un processeur de shader capable de faire de tels calculs, alors il peut servir pour les deux. Pour implémenter les shaders, il a fallu ajouter des processeurs à la carte graphique. Les processeurs en question exécutent les shaders, ils peuvent lire ou écrire dans des textures, mais ne font rien d'autres. Les ''vertex shaders'' font tout ce qui a trait à la géométrie, ils remplacent l'unité de T&L. Les pixels shaders sont entre la rastérisation et les ROPs, ils sont très liés à l'unité de texture. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | rowspan="2" class="f_rouge" | ''Vertex shader'' | rowspan="2" | Rastérisation | Placage de textures | rowspan="2" |''Raster Operations Pipeline'' |- | class="f_rouge" | ''Pixel shader'' |} <noinclude> {{NavChapitre | book=Les cartes graphiques | prev=Les cartes d'affichage des anciens PC | prevText=Les cartes d'affichage des anciens PC | next=Avant les GPUs : les cartes accélératrices 3D | nextText=Avant les GPUs : les cartes accélératrices 3D }}{{autocat}} </noinclude> am4djpz656nix61b84rf1zz3kzcpgve 765145 765144 2026-04-26T20:03:06Z Mewtow 31375 /* L'algorithme du peintre */ 765145 wikitext text/x-wiki Le premier jeu à utiliser de la "vraie 3D" texturée fut le jeu Quake, premier du nom. Et depuis sa sortie, la grande majorité des jeux vidéo utilisent de la 3D, même s'il existe encore quelques jeux en 2D. Face à la prolifération des jeux vidéo en 3D, les fabricants de cartes graphiques ont inventé les cartes accélératrices 3D, des cartes vidéo capables d'accélérer le rendu en 3D. Dans ce chapitre, nous allons voir comment elles fonctionnent et comment elles ont évolué dans le temps. Pour comprendre comment celles-ci fonctionnent, il faut faire quelques rapides rappels sur les bases du rendu 3D. ==Les bases du rendu 3D== Une '''scène 3D''' est composée d'un espace en trois dimensions, dans laquelle le moteur d’un jeu vidéo place des objets et les fait bouger. Cette scène est, en première approche, un simple parallélogramme. Un des coins de ce parallélogramme sert d’origine à un système de coordonnées : il est à la position (0, 0, 0), et les axes partent de ce point en suivant les arêtes. Les objets seront placés à des coordonnées bien précises dans ce parallélogramme. ===Les objets 3D et leur géométrie=== <noinclude>[[File:Dolphin triangle mesh.png|vignette|Illustration d'un dauphin, représenté avec des triangles.]]</noinclude> Dans la quasi-totalité des jeux vidéo actuels, les objets et la scène 3D sont modélisés par un assemblage de triangles collés les uns aux autres, ce qui porte le nom de '''maillage''', (''mesh'' en anglais). Il a été tenté dans le passé d'utiliser des quadrilatères (rendu dit en ''quad'') ou d'autres polygones, mais les contraintes techniques ont fait que ces solutions n'ont pas été retenues. [[File:CG WIKI.jpg|centre|vignette|upright=2|Exemple de modèle 3D.]] Les modèles 3D sont définis par leurs sommets, aussi appelés '''vertices''' dans le domaine du rendu 3D. Chaque sommet possède trois coordonnées, qui indiquent sa position dans la scène 3D : abscisse, ordonnée, profondeur. Les sommets sont regroupés en triangles, qui sont formés en combinant trois sommets entre eux. Les anciennes cartes graphiques géraient aussi d'autres formes géométriques, comme des points, des lignes, ou des quadrilatères. Les quadrilatères étaient appelés des ''quads'', et ce terme reviendra occasionnellement dans ce cours. De telles formes basiques, gérées nativement, sont appelées des '''primitives'''. La représentation exacte d'un objet est donc une liste plus ou moins structurée de sommets. La liste doit préciser les coordonnées de chaque sommet, ainsi que comment les relier pour former des triangles. Pour cela, l'objet est représenté par une structure qui contient la liste des sommets, mais aussi de quoi savoir quels sont les sommets reliés entre eux par un segment. Nous en dirons plus dans le chapitre sur le rendu de la géométrie. ===La caméra : le point de vue depuis l'écran=== Outre les objets proprement dit, on trouve une '''caméra''', qui représente les yeux du joueur. Cette caméra est définie au minimum par : * une position ; * par la direction du regard (un vecteur). A la caméra, il faut ajouter tout ce qui permet de déterminer le '''champ de vision'''. Le champ de vision contient tout ce qui est visible à l'écran. Et sa forme dépend de la perspective utilisée. Dans le cas le plus courant dans les jeux vidéos en 3D, il correspond à une '''pyramide de vision''' dont la pointe est la caméra, et dont les faces sont délimitées par les bords de l'écran. A l'intérieur de la pyramide, il y a un rectangle qui représente l'écran du joueur, appelé le '''''viewport'''''. <noinclude>[[File:ViewFrustum.jpg|centre|vignette|upright=2|Caméra.]]</noinclude> [[File:ViewFrustum.svg|vignette|upright=1|Volume délimité par la caméra (''view frustum'').]] La majorité des jeux vidéos ajoutent deux plans : * un ''near plane'' en-deça duquel les objets ne sont pas affichés. Il élimine du champ de vision les objets trop proches. * Un ''far plane'', un '''plan limite''' au-delà duquel on ne voit plus les objets. Il élimine les objets trop lointains. Avec ces deux plans, le champ de vision de la caméra est donc un volume en forme de pyramide tronquée, appelé le '''''view frustum'''''. Le tout est parfois appelée, bien que par abus de langage, la pyramide de vision. Avec d'autres perspectives moins utilisées, le ''view frustum'' est un pavé, mais nous n'en parlerons pas plus dans le cadre de ce cours car elles ne sont presque pas utilisés dans les jeux vidéos actuels. ===Les textures=== Tout objet à rendre en 3D est donc composé d'un assemblage de triangles, et ceux-ci sont éclairés et coloriés par divers algorithmes. Pour rajouter de la couleur, les objets sont recouverts par des '''textures''', des images qui servent de papier peint à un objet. Un objet géométrique est donc recouvert par une ou plusieurs textures qui permettent de le colorier ou de lui appliquer du relief. [[File:Texture+Mapping.jpg|centre|vignette|upright=2|Texture Mapping]] Notons que les textures sont des images comme les autres, codées pixel par pixel. Pour faire la différence entre les pixels de l'écran et les pixels d'une texture, on appelle ces derniers des '''texels'''. Ce terme est assez important, aussi profitez-en pour le mémoriser, nous le réutiliserons dans quelques chapitres. Un autre point lié au fait que les textures sont des images est leur compression, leur format. N'allez pas croire que les textures sont stockées dans un fichier .jpg, .png ou tout autre format de ce genre. Les textures utilisent des formats spécialisés, comme le DXTC1, le S3TC ou d'autres, plus adaptés à leur rôle de texture. Mais qu'il s'agisse d'images normales (.jpg, .png ou autres) ou de textures, toutes sont compressées. Les textures sont compressées pour prendre moins de mémoire. Songez que la compression de texture est terriblement efficace, souvent capable de diviser par 6 la mémoire occupée par une texture. S'en est au point où les textures restent compressées sur le disque dur, mais aussi dans la mémoire vidéo ! Nous en reparlerons dans le chapitre sur la mémoire d'une carte graphique. Plaquer une texture sur un objet peut se faire de deux manières, qui portent les noms de placage de texture inverse et direct. Le placage de texture direct a été utilisé au tout début de la 3D, sur des bornes d'arcade et les consoles de jeu 3DO, PS1, Sega Saturn. De nos jours, on utilise uniquement la technique de placage de texture inverse. Les deux seront décrites dans le détail plus bas. ===La différence entre rastérisation et lancer de rayons=== Les techniques de rendu 3D sont nombreuses, mais on peut les classer en deux grands types : le ''lancer de rayons'' et la ''rasterization''. Sans décrire les deux techniques, sachez cependant que le lancer de rayon n'est pas beaucoup utilisé pour les jeux vidéo. Il est surtout utilisé dans la production de films d'animation, d'effets spéciaux, ou d'autres rendu spéciaux. Dans les jeux vidéos, il est surtout utilisé pour quelques effets graphiques, la rasterization restant le mode de rendu principal. La raison principale est que le lancer de rayons demande beaucoup de puissance de calcul. Une autre raison est que créer des cartes accélératrices pour le lancer de rayons n'est pas simple. Il a existé des cartes accélératrices permettant d'accélérer le rendu en lancer de rayons, mais elles sont restées confidentielles. Les cartes graphiques modernes incorporent quelques circuits pour accélérer le lancer de rayons, mais ils restent d'un usage marginal et servent de compléments au rendu par rastérization. Un chapitre entier sera dédié aux cartes accélératrices de lancer de rayons et nous verrons pourquoi le lancer de rayons est difficile à implémenter avec des performances convenables, ce qui explique que les jeux vidéo utilisent la ''rasterization''. La rastérisation est structurée autour de trois étapes principales : * Une étape purement logicielle, effectuée par le processeur, où le moteur physique calcule la géométrie de la scène 3D. * Une étape de '''traitement de la géométrie''', qui gère tout ce qui a trait aux sommets et triangles. * Une étape de '''rastérisation''' qui détermine sur quels pixels de l'écran est affiché le triangle. * Une étape de '''traitement des pixels''', qui colorie les pixels et gère les textures. [[File:Graphics pipeline 2 en.svg|centre|vignette|upright=2.5|Pipeline graphique basique.]] Il existe plusieurs rendus différents et la rastérisation ne se fait pas de la même manière selon le rendu utilisé. Il existe des rendus sans textures, d'autres avec, d'autres avec éclairage, d'autres sans, etc. Par contre, l'étape de calcul de la géométrie est la même quel que soit le rendu ! Mieux : le calcul de la géométrie se fait de la même manière entre rastérisation et lancer de rayons, il est le même quelle que soit la technique de rendu 3D utilisée. Les trois étapes précédentes sont réalisées dans des circuits ou processeurs séparés, comme on le verra plus tard. Et cela permet d'utiliser la technique dite du '''pipeline'''. Concrètement, supposons que la carte graphique traite les données par paquets de triangles (en réalité, c'est des paquets de sommets, mais passons). L'étape de traitement de la géométrie peut travailler sur un paquet de triangle, pendant que le paquet précédent est dans l'étape de rastérisation, et que le paquet encore précédent est en train de traiter ses pixels. Cela permet de traiter trois paquets de triangles en même temps, mais à des états d'avancements différents. Mieux que cela : le traitement de la géométrie est lui-même composé d'une succession de sous-étapes, la rasterisation est elle-même découpée en plusieurs sous-étapes, et ainsi de suite. Le nombre d'étapes pour une carte graphique moderne dépasse la dizaine. La rastérisation calcule un rendu 3D avec une suite d'étapes consécutives qui doivent s'enchainer dans un ordre bien précis. L'ensemble de ces étapes est appelé le '''pipeline graphique''', qui sera détaillé dans ce qui suit. ==Le calcul de la géométrie== Le calcul de la géométrie regroupe plusieurs manipulations différentes. La principale demande juste de placer les modèles 3D dans la scène, de placer les objets dans le monde. Puis, il faut centrer la scène 3D sur la caméra. Les deux changements ont pour point commun de demander des changements de repères. Par changement de repères, on veut dire que l'on passe d'un système de coordonnées à un autre. En tout, il existe trois changements de repères distincts qui sont regroupés dans l''''étape de transformation''' : un premier qui place chaque objet 3D dans la scène 3D, un autre qui centre la scène du point de vue de la caméra, et un autre qui corrige la perspective. ===Les trois étapes de transformation=== La première étape place les objets 3D dans la scène 3D. Un modèle 3D est représentée par un ensemble de sommets, qui sont reliés pour former sa surface. Les données du modèle 3D indiquent, pour chaque sommet, sa position par rapport au centre de l'objet qui a les coordonnées (0, 0, 0). La première étape place l'objet 3D à une position dans la scène 3D, déterminée par le moteur physique, qui a des coordonnées (X, Y, Z). Une fois placé dans la scène 3D, le centre de l'objet passe donc des coordonnées (0, 0, 0) aux coordonnées (X, Y, Z) et tous les sommets de l'objet doivent être mis à jour. De plus, l'objet a une certaine orientation : il faut aussi le faire tourner. Enfin, l'objet peut aussi subir une mise à l'échelle : on peut le gonfler ou le faire rapetisser, du moment que cela ne modifie pas sa forme, mais simplement sa taille. En clair, le modèle 3D subit une translation, une rotation et une mise à l'échelle, les trois impliquant une modification des coordonnées des sommets.. [[File:Similarity and congruence transformations.svg|centre|vignette|upright=1.5|Transformations géométriques possibles pour chaque triangle.]] Une fois le placement des différents objets effectué, la carte graphique effectue un changement de coordonnées pour centrer le monde sur la caméra. Au lieu de considérer un des bords de la scène 3D comme étant le point de coordonnées (0, 0, 0), il va passer dans le référentiel de la caméra. Après cette transformation, le point de coordonnées (0, 0, 0) sera la caméra. La direction de la vue du joueur sera alignée avec l'axe de la profondeur (l'axe Z). [[File:View transform.svg|centre|vignette|upright=2|Étape de transformation dans un environnement en deux dimensions : avant et après. On voit que l'on centre le monde sur la position de la caméra et dans sa direction.]] Enfin, il faut aussi corriger la perspective, ce qui est le fait de l'étape de projection, qui modifie la forme du ''view frustum'' sans en modifier le contenu. Différents types de perspective existent et celles-ci ont un impact différent les unes des autres sur le ''view frustum''. Dans le cas qui nous intéresse, le ''view frustum'' passe d’une forme de trapèze tridimensionnel à une forme de pavé dont l'écran est une des faces. ===Les changements de coordonnées se font via des multiplications de matrices=== Les trois étapes précédentes demande de faire des changements de coordonnées, chaque sommet voyant ses coordonnées remplacées par de nouvelles. Or, un changement de coordonnée s'effectue assez simplement, avec des matrices, à savoir des tableaux organisés en lignes et en colonnes avec un nombre dans chaque case. Un changement de coordonnées se fait simplement en multipliant le vecteur (X, Y, Z) des coordonnées d'un sommet par une matrice adéquate. Il existe des matrices pour la translation, la mise à l'échelle, d'autres pour la rotation, une autre pour la transformation de la caméra, une autre pour l'étape de projection, etc. Un changement de coordonnée s'effectue assez simplement en multipliant le vecteur-coordonnées (X, Y, Z) d'un sommet par une matrice adéquate. Un petit problème est que les matrices qui le permettent sont des matrices avec 4 lignes et 4 colonnes. Or, la multiplication demande que le nombre de coordonnées du vecteur soit égal au nombre de colonnes. Pour résoudre ce petit problème, on ajoute une 4éme coordonnée aux sommets, la coordonnée homogène, qui ne sert à rien, et est souvent mise à 1, par défaut. Mais oublions ce détail. Il se trouve que multiplier des matrices amène certaines simplifications. Au lieu de faire plusieurs multiplications de matrices, il est possible de fusionner les matrices en une seule, ce qui permet de simplifier les calculs. Ce qui fait que le placement des objets, changement de repère pour centrer la caméra, et d'autres traitements forts différents sont regroupés ensemble. Le traitement de la géométrie implique, sans surprise, des calculs de géométrie dans l'espace. Et cela implique des opérations mathématiques aux noms barbares : produits scalaires, produits vectoriels, et autres calculs impliquant des vecteurs et/ou des matrices. Et les calculs vectoriels/matriciels impliquent beaucoup d'additions, de soustractions, de multiplications, de division, mais aussi des opérations plus complexes : calculs trigonométriques, racines carrées, inverse d'une racine carrée, etc. Au final, un simple processeur peut faire ce genre de calculs, si on lui fournit le programme adéquat, l'implémentation est assez aisée. Mais on peut aussi implémenter le tout avec un circuit spécialisé, non-programmable. Les deux solutions sont possibles, tant que le circuit dispose d'assez de puissance de calcul. Les cartes graphiques anciennes contenaient un ou plusieurs circuits de multiplication de matrices spécialisés dans l'étape de transformation. Chacun de ces circuits prend un sommet et renvoie le sommet transformé. Ils sont composés d'un gros paquet de multiplieurs et d'additionneurs flottants. Pour plus d'efficacité, les cartes graphiques comportent plusieurs de ces circuits, afin de pouvoir traiter plusieurs sommets en même temps. ==L'élimination des surfaces cachées== Un point important du rendu 3D est que ce que certaines portions de la scène 3D ne sont pas visibles depuis la caméra. Et idéalement, les portions de la scène 3D qui ne sont pas visibles à l'écran ne doivent pas être calculées. A quoi bon calculer des choses qui ne seront pas affichées ? Ce serait gâcher de la puissance de calcul. Et pour cela, de nombreuses optimisations visent à éliminer les calculs inutiles. Elles sont regroupées sous les termes de '''''clipping''''' ou de '''''culling'''''. La différence entre ''culling'' et ''clipping'' n'est pas fixée et la terminologie n'est pas claire. Dans ce qui va suivre, nous n'utiliserons que le terme ''culling''. Les cartes graphiques modernes embarquent diverses méthodes de ''culling'' pour abandonner les calculs quand elles s’aperçoivent que ceux-ci portent sur une partie non-affichée de l'image. Cela fait des économies de puissance de calcul assez appréciables et un gain en performance assez important. Précisons que le ''culling'' peut être plus ou moins précoce suivant le type de rendu 3D utilisé, mais nous verrons cela dans la suite du chapitre. ===Les différentes formes de ''culling''/''clipping''=== La première forme de ''culling'' est le '''''view frustum culling''''', dont le nom indique qu'il s'agit de l'élimination de tout ce qui est situé en-dehors du ''view frustum''. Ce qui est en-dehors du champ de vision de la caméra n'est pas affiché à l'écran n'est pas calculé ou rendu, dans une certaine mesure. Le ''view frustum culling'' est assez trivial : il suffit d'éliminer ce qui n'est pas dans le ''view frustum'' avec quelques calculs de coordonnées assez simples. Quelques subtilités surviennent quand un triangle est partiellement dans le ''view frustrum'', ce qui arrive parfois si le triangle est sur un bord de l'écran. Mais rien d'insurmontable. [[File:View frustum culling.svg|centre|vignette|upright=1|''View frustum culling'' : les parties potentiellement visibles sont en vert, celles invisibles en rouge et celles partiellement visibles en bleu.]] Les autres formes de ''culling'' visent à éliminer ce qui est dans le ''view frustum'', mais qui n'est pas visible depuis la caméra. Pensez à des objets cachés par un autre objet plus proche, par exemple. Ou encore, pensez aux faces à l'arrière d'un objet opaque qui sont cachées par l'avant. Ces deux cas correspondent à deux types de ''culling''. L'élimination des objets masqués par d'autres est appelé l'''occlusion culling''. L'élimination des parties arrières d'un objet est appelé le ''back-face culling''. Dans les deux cas, nous parlerons d''''élimination des surfaces cachées'''. [[File:Occlusion culling example PL.svg|centre|vignette|''Occlusion culling'' : les objets en bleu sont visibles, ceux en rouge sont masqués par les objets en bleu.]] Le lancer de rayons n'a pas besoin d'éliminer les surfaces cachées, il ne calcule que les surfaces visibles. Par contre, la rastérisation demande d'éliminer les surfaces cachées. Sans cela, le rendu est incorrect dans le pire des cas, ou alors le rendu calcule des surfaces invisibles pour rien. Il existe de nombreux algorithmes logiciels pour implémenter l'élimination des surfaces cachées, mais la carte graphique peut aussi s'en charger. L'''occlusion culling'' demande de connaitre la distance à la caméra de chaque triangle. La distance à la caméra est appelée la '''profondeur''' du triangle. Elle est déterminée à l'étape de rastérisation et est calculée à chaque sommet. Lors de la rastérisation, chaque sommet se voit attribuer trois coordonnées : deux coordonnées x et y qui indiquent sa position à l'écran, et une coordonnée de profondeur notée z. ===L'algorithme du peintre=== Pour éliminer les surfaces cachées, la solution la plus simple consiste simplement à rendre les triangles du plus lointain au plus proche. L'idée est que si deux triangles se recouvrent totalement ou partiellement, on doit dessiner celui qui est derrière, puis celui qui est devant. Le dessin du second va recouvrir le premier. Quelque chose qui devrait vous rappeler le rendu 2D, où les sprites sont rendus du plus lointain au plus proche. Il ne s'agit ni plus ni moins que de l''''algorithme du peintre'''. [[File:Polygons cross.svg|vignette|Polygons cross]] Un problème est que la solution ne marche pas avec certaines configurations particulières, dans le cas où des polygones un peu complexes se chevauchent plusieurs fois. Il se présente rarement dans un rendu 3D normal, mais c'est quand même un cas qu'il faut gérer. Le problème est suffisant pour que cette solution ne soit plus utilisée dans le rendu 3D normal. Un autre problème est que l'algorithme demande de trier les triangles d'une scène 3D selon leur profondeur, du plus profond au moins profond. Et les cartes graphiques n'aiment pas ça, que ce soit les anciennes cartes graphiques comme les modernes. Il s'agit généralement d'une tâche qui est réalisée par le processeur, le CPU, qui est plus efficace que le GPU pour trier des trucs. Aussi, l'algorithme du peintre était utilisé sur d'anciennes cartes graphiques, qui ne géraient pas la géométrie mais seulement les textures et quelques effets de post-processing. Avec ces GPU, les jeux vidéo calculaient la géométrie et la triait sur le CPU, puis effectuaient le reste de la rastérisation sur le GPU. Les anciens jeux en 2.5D comme DOOM ou les DOOM-like, utilisaient une amélioration de l'algorithme du peintre. L'amélioration variait suivant le moteur de jeu utilisé, et donnait soit une technique dite de ''portal rendering'', soit un système de ''Binary Space Partionning'', assez complexes et difficiles à expliquer. Mais il ne s'agissait pas de jeux en 3D, les maps de ces jeux avaient des contraintes qui rendaient cette technique utilisable. Ils n'avaient pas de polygones qui se chevauchent, notamment. ===Le tampon de profondeur=== [[File:Z-buffer no text.jpg|vignette|Z-buffer correspondant à un rendu]] Une autre solution utilise ce qu'on appelle un '''tampon de profondeur''', aussi appelé un ''z-buffer''. Il s'agit d'un tableau, stocké en mémoire vidéo, qui mémorise la coordonnée z de l'objet le plus proche pour chaque pixel. Par défaut, ce tampon de profondeur est initialisé avec la valeur de profondeur maximale, celle du ''far plane'' du ''viewfrustum''. Au fur et à mesure que les objets seront calculés, le tampon de profondeur est mis à jour, conservant ainsi la trace de l'objet le plus proche de la caméra. Si jamais un triangle a une coordonnée z plus grande que celle du tampon de profondeur, cela veut dire qu'il est situé derrière un objet déjà rendu. Il est éliminé (sauf si transparence il y a) et le tampon de profondeur n'a pas à être mis à jour. Dans le cas contraire, l'objet est plus près de la caméra et sa coordonnée z remplace l'ancienne valeur z dans le tampon de profondeur. [[File:Z-buffer.svg|centre|vignette|upright=2.0|Illustration du processus de mise à jour du Z-buffer.]] Il existe des techniques alternatives pour coder la coordonnée de profondeur, qui se distinguent par le fait que la coordonnée z n'est pas proportionnelle à la distance entre le fragment et la caméra. Mais il s'agit là de détails assez mathématiques que je me permets de passer sous silence. Dans la suite de ce cours, nous allons juste parler de profondeur pour regrouper toutes ces techniques, conventionnelles ou alternatives. Toutes les cartes graphiques modernes utilisent un système de ''z-buffer''. C'est la seule solution pour avoir des performances dignes de ce nom. Il faut cependant noter qu'elles utilisent des tampons de profondeur légèrement modifiés, qui ne mémorisent pas la coordonnée de profondeur, mais une valeur dérivée. Pour simplifier, ils ne mémorisent pas la coordonnée de profondeur z, mais son inverse 1/z. Les raisons à cela ne peuvent pas encore être expliquées à ce moment du cours, aussi nous allons simplement dire que c'est une histoire de correction de perspective. Les coordonnées z et 1/z sont codées sur quelques bits, allant de 16 bits pour les anciennes cartes graphiques, à 24/32 bits pour les cartes plus récentes. De nos jours, les Z-buffer de 16 bits sont abandonnés et toutes les cartes graphiques utilisent des coordonnées z de 24 à 32 bits. La raison est que les Z-buffer de 16 bits ont une précision insuffisante, ce qui fait que des artefacts peuvent survenir. Si deux objets sont suffisamment proches, le tampon de profondeur n'a pas la précision suffisante pour discriminer les deux objets. Pour lui, les deux objets sont à la même place. Conséquence : il faut bien choisir un des deux objets et ce choix se fait pixel par pixel, ce qui fait des artefacts visuels apparaissent. On parle alors de '''''z-fighting'''''. Voici ce que cela donne : [[File:Z-fighting.png|centre|vignette|Z-fighting]] Un défaut du tampon de profondeur est qu'il ne gère pas correctement les objets transparents. Dès que de la transparence est présente dans une scène 3D, le tampon de profondeur ne peut pas être utilisé. Une solution pour cela est de rendre une scène 3D en deux phases : une pour les objets opaques, une avec les objets transparents. La où on rend les objets opaques utilise le tampon de profondeur, mais il est désactivé lors de la seconde. ==La rastérisation et les textures== Dans cette section, nous allons voir ensemble l'étape de rastérisation et l'étape de traitement des pixels. La rastérisation est difficile à expliquer, surtout que son rôle exact dépend de la technique de rendu utilisée. Pour simplifier, elle projette un rendu en 3D sur un écran en 2D. Une autre explication tout aussi vague est qu'elle s'occupe la traduction des triangles en un affichage pixelisé à l'écran. Elle détermine à quoi ressemble la scène visible sur l'écran. C'est par exemple lors de cette étape que sont appliquées certaines techniques de ''culling'', qui éliminent les portions non-visibles de l'image, ainsi qu'une correction de la perspective et diverses opérations d'interpolation dont nous parlerons dans plusieurs chapitres. La rastérisation et placage de textures sont deux opérations très liées entre elles. Il existe deux manières principales pour lier les textures à la géométrie : la méthode directe et la méthode inverse (''UV Mapping''). Et les deux font que la rastérisation se fait de manière très différente. Précisons cependant que les rendus les plus simples n'utilisent pas de textures du tout. Ils se contentent de colorier les triangles, voire d'un simple rendu en fil de fer basé sur du tracé de lignes. Dans la suite de cette section, nous allons voir les quatre types de rendu principaux : le rendu en fils de fer, le rendu colorié, et deux rendus utilisant des textures. ===Le rendu en fil de fer=== [[File:Obj lineremoval.png|vignette|Rendu en fil de fer d'un objet 3D.]] Le '''rendu 3D en fils de fer''' est illustré ci-contre. Il s'agit d'un rendu assez ancien, utilisé au tout début de la 3D, sur des machines qu'on aurait du mal à appeler ordinateurs. Il se contente de tracer des lignes à l'écran, lignes qui connectent deux sommets, qui ne sont autres que les arêtes de la géométrie de la scène rendue. Le tout était suffisant pour réaliser quelques jeux vidéos rudimentaires. Les tout premiers jeux vidéos utilisaient ce rendu, l'un d'entre eux étant Maze War, le tout premier FPS. {| |[[File:Maze war.jpg|vignette|Maze war]] |[[File:Maze representation using wireframes 2022-01-10.gif|centre|vignette|Maze representation using wireframes 2022-01-10]] |} Le monde est calculé en 3D, il y a toujours un calcul de la géométrie, la scène est rastérisée normalement, les portions invisbles de l'image sont retirées, mais il n'y a pas d'application de textures après rastérisation. A la place, un algorithme de tracé de ligne trace les lignes à l'écran. Quand un triangle passe l'étape de rastérisation, l'étape de rastérisation fournit la position des trois sommets sur l'écran. En clair, elle fournit les coordonnées de trois pixels, un par sommet. A la suite, un algorithme de tracé de ligne trace trois lignes, une par paire de sommet. L'implémentation demande juste d'avoir une unité de calcul géométrique, une unité de rastérisation, et un VDC qui supporte le tracé de lignes. Elle est donc assez simple et ne demande pas de circuits de gestion des textures ni de ROP. Le VDC écrit directement dans le ''framebuffer'' les lignes à tracer. Il a existé des proto-cartes graphiques spécialisées dans ce genre de rendu, comme le '''''Line Drawing System-1''''' de l'entreprise Eans & Sutherland. Nous détaillerons son fonctionnement dans quelques chapitres. ===Le rendu à primitives colorées=== [[File:MiniFighter.png|vignette|upright=1|Exemple de rendu pouvant être obtenu avec des sommets colorés.]] Une amélioration du rendu précédent utilise des triangles/''quads'' coloriés. Chaque triangle ou ''quad'' est associé à une couleur, et cette couleur est dessinée sur le triangle/''quad''après la rastérisation. Le rendu est une amélioration du rendu en fils de fer. L'idée est que chaque triangle/''quad'' est associé à une couleur, qui est dessinée sur le triangle/''quad'' après la rastérisation. La technique est nommée ''colored vertices'' en anglais, nous parlerons de '''rendu à maillage coloré'''. [[File:Malla irregular de triángulos modelizando una superficie convexa.png|centre|vignette|upright=2|Maillage coloré.]] La couleur est propagée lors des calculs géométriques et de la rastérisation, sans subir de modifications. Une fois un rendu en fils de fer effectué, la couleur du triangle est récupérée. Le triangle/''quad'' rendu correspond à un triangle/''quad'' à l'écran. Et l'intérieur de ce triangle/''quad'' est colorié avec la couleur transmise. Pour cela, on utilise encore une fois une fonction du VDC : celle du remplissage de figure géométrique. Nous l’avions vu en parlant des VDC à accélération 2D, mais elle est souvent prise en charge par les ''blitters''. Ils peuvent remplir une figure géométrique avec une couleur unique, on réutilise cette fonction pour colorier le triangle/''quad''. L'étape de rastérisation fournit les coordonnées des sommets de la figure géométrique, le ''blitter'' les utilise pour colorier la figure géométrique. Niveau matériel, quelques bornes d'arcade ont utilisé ce rendu. La toute première borne d'arcade utilisant le rendu à maillage coloré est celle du jeu I Robot, d'Atari, sorti en 1983. Par la suite, dès 1988, les cartes d'arcades Namco System 21 et les bornes d'arcades Sega Model 1 utilisaient ce genre de rendu. On peut s'en rendre compte en regardant les graphismes des jeux tournant sur ces bornes d'arcade. Des jeux comme Virtua Racing, Virtua Fighter ou Virtua Formula sont assez parlants à ce niveau. Leurs graphismes sont assez anguleux et on voit qu'ils sont basés sur des triangles uniformément colorés. Pour ceux qui veulent en savoir plus sur la toute première borne d'arcade en rendu à maillage colorée, la borne ''I Robot'' d'Atari, voici une vidéo youtube à ce sujet : * [https://www.youtube.com/watch?v=6miEkPENsT0 I Robot d'Atari, le pionnier de la 3D Flat.] ===Le placage de textures direct=== Les deux rendus précédents sont très simples, mais n'utilisent pas de textures. Et il est temps de voir les deux rendus qui utilisent des textures. Il y en a deux types, appelés rendu avec placage de texture direct et indirect, nous allons voir le '''rendu par placage de texture direct''' en premier. Et nous l'appellerons ''rendu direct'' dans ce qui suit, pour simplifier les explications. L'idée est assez simple et peut utiliser aussi bien des triangles que des ''quads'', mais nous allons partir du principe qu'elle utilise des '''''quads''''', à savoir que les objets 3D sont composés de quadrilatères. Lorsqu'un ''quad'' est rastérisé, sa forme à l'écran est un rectangle déformé par la perspective. On obtient un rectangle si le ''quad'' est vu de face, un trapèze si on le voit de biais. Et le ''sprite'' doit être déformé de la même manière que le ''quad''. L'idée est que tout quad est associé à une texture, à un sprite. La figure géométrique qui correspond à un ''quad'' à l'écran est remplie non pas par une couleur uniforme, mais par un ''sprite'' rectangulaire. Il suffit techniquement de recopier le ''sprite'' à l'écran, c'est à dire dans la figure géométrique, au bon endroit dans le ''framebuffer''. Le rendu direct est en effet un intermédiaire entre rendu 2D à base de ''sprite'' et rendu 3D moderne. La géométrie est rendue en 3D pour générer des ''quads'', mais ces ''quads'' ne servent à guider la copie des sprites/textures dans le ''framebuffer''. [[File:TextureMapping.png|centre|vignette|upright=2|Exemple caricatural de placage de texture sur un ''quad''.]] La subtilité est que le sprite est déformé de manière à rentrer dans un quadrilatère, qui n'est pas forcément un rectangle à l'écran, mais est déformé par la perspective et son orientation en 3D. Le sprite doit être déformé de deux manières : il doit être agrandi/réduit en fonction de la taille de la figure affichée à l'écran, tourné en fonction de l'orientation du ''quad'', déformé pour gérer la perspective. Pour cela, il faut connaitre les coordonnées de profondeur de chaque bord d'un ''quad'', et de faire quelques calculs. N'importe quel VDC incluant un ''blitter'' avec une gestion du zoom/rotation des sprites peut le faire. : Si on veut avoir de beaux graphismes, il vaut mieux appliquer un filtre pour lisser le sprite envoyé dans le trapèze, filtre qui se résume à une opération d'interpolation et n'est pas très différent du filtrage de texture qui lisse les textures à l'écran. Un autre point est que les ''quads'' doivent être rendus du plus lointain au plus proche. Sans cela, on obtient rapidement des erreurs de rendu. L'idée est que si deux quads se chevauchent, on doit dessiner celui qui est derrière, puis celui qui est devant. Le dessin du second va recouvrir le premier. L'écriture du sprite du second quad écrasera les données du premier quad, pour les portions recouvertes, lors de l'écriture du sprite dans le ''framebuffer''. Quelque chose qui devrait vous rappeler le rendu 2D, où les sprites sont rendus du plus lointain au plus proche. Le rendu inverse utilise très souvent des triangles pour la géométrie, alors que le rendu direct a tendance à utiliser des ''quads'', mais il ne s'agit pas d'une différence stricte. L'usage de triangles/''quads'' peut se faire aussi bien avec un rendu direct comme avec un rendu inverse. Cependant, le rendu en ''quad'' se marie très bien au rendu direct, alors que le rendu en triangle colle mieux au rendu inverse. L'avantage de cette technique est qu'on parcourt les textures dans un ordre bien précis. Par exemple, on peut parcourir la texture ligne par ligne, l'exploiter par blocs de 4*4 pixels, etc. Et accéder à une texture de manière prédictible se marie bien avec l'usage de mémoires caches, ce qui est un avantage en matière de performances. Mais un même pixel du ''framebuffer'' est écrit plusieurs fois quand plusieurs quads se superposent, alors que le rendu inverse gère la situation avec une seule écriture (sauf si usage de la transparence). De plus, la gestion de la transparence était compliquée et les jeux devaient ruser en utilisation des solutions logicielles assez complexes. Niveau implémentation matérielle, une carte graphique en rendu direct demande juste trois circuits. Le premier est un circuit de calcul géométrique, qui rend la scène 3D. Le tri des quads est souvent réalisé par le processeur principal, et non pas par un circuit séparé. Toutes les étapes au-delà de l'étape de rastérisation étaient prises en charge par un VDC amélioré, qui écrivait des sprites/textures directement dans le ''framebuffer''. {|class="wikitable" |- ! Géométrie | Processeurs dédiés programmé pour émuler le pipeline graphique |- ! Tri des quads du plus lointain au plus proche | Processeur principal (implémentation logicielle) |- ! Application des textures | ''Blitter'' amélioré, capable de faire tourner et de zoomer sur des ''sprites''. |} L'implémentation était très simple et réutilisait des composants déjà existants : des VDC 2D pour l'application des textures, des processeurs dédiés pour la géométrie. Les unités de calcul de la géométrie étaient généralement implémentées avec un ou plusieurs processeurs dédiés. Vu qu'on savait déjà effectuer le rendu géométrique en logiciel, pas besoin de créer un circuit sur mesure. Il suffisait de dédier un processeur spécialisé rien que pour les calculs géométriques et on lui faisait exécuter un code déjà bien connu à la base. En clair, ils utilisaient un code spécifique pour émuler un circuit fixe. C'était clairement la solution la plus adaptée pour l'époque. Les unités géométriques étaient des processeurs RISC, normalement utilisés dans l'embarqué ou sur des serveurs. Elles utilisaient parfois des DSP. Pour rappel, les DSP des processeurs de traitement de signal assez communs, pas spécialement dédiés aux rendu 3D, mais spécialisé dans le traitement de signal audio, vidéo et autre. Ils avaient un jeu d'instruction assez proche de celui des cartes graphiques actuelles, et supportaient de nombreuses instructions utiles pour le rendu 3D. [[File:Sega ST-V Dynamite Deka PCB 20100324.jpg|vignette|Sega ST-V Dynamite Deka PCB 20100324]] Le rendu direct a été utilisé dans la période de transition entre rendu 2D et rendu 3D, car il était très adapté pour faire cette transition. Coupler un VDC à un processeur pour la géométrie était particulièrement simple à l'époque. Le rendu direct a été utilisé sur des bornes d'arcade dès les années 90. Outre les bornes d'arcade, quelques consoles de 5ème génération utilisaient le rendu direct, avec les mêmes solutions matérielles. La géométrie était calculée sur plusieurs processeurs dédiés. Le reste du pipeline était géré par un VDC 2D qui implémentait le placage de textures. Deux consoles étaient dans ce cas : la 3DO, et la Sega Saturn. Le rendu direct est aujourd'hui abandonné. ===Le placage de textures inverse=== Le rendu précédent, le rendu direct, permet d'appliquer des textures directement dans le ''framebuffer''. Mais comme dit plus haut, il existe une seconde technique pour plaquer des textures, appelé le '''placage de texture inverse''', aussi appelé l'''UV Mapping''. Elle associe une texture complète pour un modèle 3D,contrairement au placage de tecture direct qui associe une texture par ''quad''/triangle. L'idée est que l'on attribue un texel à chaque sommet. Plus précisémment, chaque sommet est associé à des '''coordonnées de texture''', qui précisent quelle texture appliquer, mais aussi où se situe le texel à appliquer dans la texture. Par exemple, la coordonnée de texture peut dire : je veux le pixel qui est à ligne 5, colonne 27 dans cette texture. La correspondance entre texture et géométrie est réalisée lorsque les créateurs de jeu vidéo conçoivent le modèle de l'objet. [[File:Texture Mapping example.png|centre|vignette|upright=2|Exemple de placage de texture.]] Dans les faits, on n'utilise pas de coordonnées entières de ce type, mais deux nombres flottants compris entre 0 et 1. La coordonnée 0,0 correspond au texel en bas à gauche, celui de coordonnée 1,1 est tout en haut à droite. L'avantage est que ces coordonnées sont indépendantes de la résolution de la texture, ce qui aura des avantages pour certaines techniques de rendu, comme le ''mip-mapping''. Les deux coordonnées de texture sont notées u,v avec DirectX, ou encore s,t dans le cas général : u est la coordonnée horizontale, v la verticale. [[File:UVMapping.png|centre|vignette|upright=2|UV Mapping]] Avec le placage de texture inverse, la rastérisation se fait grosso-modo en trois étapes : la rastérisation proprement dite, le placage de textures, et les opérations finales qui écrivent un pixel dans le ''framebuffer''. Au niveau du matériel, ainsi que dans la plupart des API 3D, les trois étapes sont réalisées par des circuits séparés. [[File:01 3D-Rasterung-a.svg|vignette|Illustration du principe de la rasterization. La surface correspondant à l'écran est subdivisée en pixels carrés, de coordonnées x et y. La caméra est placée au point e. Pour chaque pixel, on trace une droite qui part de la caméra et qui passe par le pixel considéré. L'intersection entre une surface et cette droite se fait en un point, appartenant à un triangle.]] Lors de la rasterisation, chaque triangle se voit attribuer un ou plusieurs pixels à l'écran. Pour bien comprendre, imaginez une ligne droite qui part de caméra et qui passe par un pixel sur le plan de l'écran. Cette ligne intersecte 0, 1 ou plusieurs objets dans la scène 3D. Les triangles situés ces intersections entre cette ligne et les objets rencontrés seront associés au pixel correspondant. L'étape de rastérisation prend en entrée un triangle et renvoie la coordonnée x,y du pixel associé. Il s'agit là d'une simplification, car un triangle tend à occuper plusieurs pixels sur l'écran. L'étape de rastérisation fournit la liste de tous les pixels occupés par un triangle, et les traite un par un. Quand un triangle est rastérisé, le rasteriseur détermine la coordonnée x,y du premier pixel, applique une texture dessus, puis passe au suivant, et rebelote jusqu'à ce que tous les pixels occupés par le triangles aient été traités. L'implémentation matérielle du placage de texture inverse est beaucoup plus complexe que pour les autres techniques. Pour être franc, nous allons passer le reste du cours à parler de l'implémentation matérielle du placage de texture inverse, ce qui prendra plus d'une dizaine de chapitres. ==La transparence, les fragments et les ROPs== Dans ce qui suit, nous allons parler uniquement de la rastérisation avec placage de textures inverse. Les autres formes de rastérisation ne seront pas abordées. La raison est que tous les GPUs modernes utilisent cette forme de rastérisation, les exceptions étant rares. De même, ils utilisent un tampon de profondeur, pour l'élimination des surfaces cachées. La rastérisation effectue donc des calculs géométriques, suivis d'une étape de rastérisation, puis de placage des textures. Ces trois étapes sont réalisées par une unité géométrique, une unité de rastérisation, et un circuit de placage de textures. Du moins sur le principe, car les cartes graphiques modernes ont fortement optimisé l'implémentation et n'ont pas hésité à fusionner certains circuits. Mais nous verrons cela en temps voulu, nous n'allons pas résumer plusieurs décennies d'innovation technologique en quelques paragraphes. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Placage de textures |} Mais où mettre le tampon de profondeur ? Intuitivement, on se dit qu'il vaut mieux faire l'élimination des surfaces cachées le plus tôt possible, dès que la coordonnée de profondeur est connue. Et elle est connu à l'étape de rastérisation, une fois les sommets transformés. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Tampon de profondeur | Placage de textures |} En réalité, la profondeur des fragments est gérée par un circuit appelé le '''''Raster Operations Pipeline''''' (ROP), situé à la toute fin du pipeline graphique. Dans ce qui suit, nous utiliserons l'abréviation ROP pour simplifier les explications. Le ROP effectue quelques traitements sur les fragments, avant d'enregistrer l'image finale dans la mémoire vidéo. Il est placé à la fin du pipeline pour gérer correctement la transparence. Et nous allons voir pourquoi la transparence est gérée à la fin du pipeline. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Placage de textures | ''Raster Operations Pipeline'' |} ===Le mélange ''alpha''=== La transparence se manifeste quand plusieurs objets sont l'un derrière l'autre. Histoire de simplifier les explications, nous allons d'abord voir le cas où un objet semi-transparent est devant un objet opaque. La couleur perçue est alors un mélange de la couleur de l'objet opaque et celle de l'objet semi-transparent. Le mélange dépend d'à quel point l'objet semi-transparent est transparent. Avec un objet parfaitement transparent, seul l'objet opaque est visible. Avec un objet à moitié transparent, la couleur finale sera pour moitié celle de l'objet opaque, pour moitié celle de l'objet semi-transparent. Et c'est pareil pour les cas intermédiaires entre un objet totalement transparent et un objet totalement opaque. La transparence d'un objet/pixel est définie par un nombre, appelé la '''composante ''alpha'''''. Plus la composante alpha est élevée, plus le pixel est opaque. Elle vaut 0 pour un objet opaque et 1 pour un objet transparent. Elle est ajoutée aux composantes RGB, ce qui fait que tout fragment contient une "couleur de transparence" en plus des couleurs RGB. Elle agit comme un coefficient qui dit comment mélanger la couleur d'un objet transparent et d'un objet opaque. Le calcul de la transparence est une moyenne pondérée par la composante alpha. On parle alors d''''''alpha blending'''''. : <math>\text{Couleur finale} = \alpha \times \text{Couleur de l'objet transparent} + (1 - \alpha) \times \text{Couleur de l'objet opaque}</math> [[File:Texture splatting.png|centre|vignette|upright=2.0|Calcul de transparence. La première ligne montre le produit pour l'objet transparent, la seconde ligne est celle de l'objet opaque. La troisième ligne est celle de l'addition finale.]] Maintenant, qu'en est-il du cas où plusieurs objets sont superposés ? Si vous tracez une demi-droite dont l'origine est la caméra et qui passe par le pixel, il arrive qu'elle intersecte la géométrie en plusieurs points, un point par objet sur la ligne du regarde. Sans transparence, l'objet le plus proche cache tous les autres et c'est donc lui qui décide de la couleur du pixel. Mais avec un objet transparent, la couleur finale est un mélange de la couleur de plusieurs points d'intersection. Il faut donc calculer un pseudo-pixel pour chaque point d'intersection, auquel on donne le nom de '''fragment'''. Un fragment possède une position à l'écran, une coordonnée de profondeur, une couleur, ainsi que quelques autres informations potentiellement utiles. Les fragments attribués à un même pixel, qui sont à la même position sur l'écran, sont combinés pour obtenir la couleur finale de ce pixel. Il est possible d'utiliser le mélange ''alpha'' pour cela. Il suffit de faire le mélange ''alpha'' entre le fragment qui vient d'être calculé, et le pixel dans le ''framebuffer''. Pour cela, le fragment a une composante ''alpha'', qui est ajouté aux trois couleurs RGB. Le pixel déjà dans le ''framebuffer'' est un résultat temporaire, né du mélange ''alpha'' de tous les fragments précédents. Un défaut de cette méthode est qu'elle fonctionne assez mal avec un tampon de profondeur. Si le tampon de profondeur est activé, le mélange ''alpha'' ne fonctionne que si les objets sont rendus du plus lointain au plus proche. Et procéder dans cet ordre a un défaut : on dessine des objets dans le ''framebuffer'', pour qu'ensuite les objets devant écrasent ce qui a déjà été dessiné. Un même pixel peut donc être dessiné plusieurs fois, dont une seule sera pertinente. Et ces écritures utilisent de la bande passante mémoire, qui est une ressource précieuse sur un GPU moderne. Il s'agit d'un phénomène appelé '''''overdraw''''', ou sur-dessinage en français. Quelques optimisations permettent d'éliminer l'''overdraw'' en rendant les objets du plus proche au plus lointain, d'autres permettent de dessiner des objets dans un ordre arbitraire, mais nous ne pouvons pas en parler ici. Beaucoup de moteurs 3D rendent séparément les objets opaques et transparents. Une première passe rend les objets opaques, puis les objets transparents sont rendus dans une seconde passe. Les objets opaques sont rendus dans le désordre, ce qui fait qu'on n'a pas à les trier, alors que les objets transparents doivent être triés selon leur distance. un autre avantage est que le mélange ''alpha'' est désactivé lors de la première passe, alors que c'est la mise à jour du tampon de profondeur qui est désactivé lors de la seconde passe, ce qui augmente un peu les performances dans les deux cas. ===Le test ''alpha''=== Le test ''alpha'' est une technique qui permet d'annuler le rendu d'un fragment en fonction de sa transparence. Si la composante alpha est en-dessous ou au-dessus d'un seuil, le fragment est simplement abandonné. Le seuil en question est configurable, de même que la comparaison utilisée : on peut éliminer le fragment si sa transparence est au-dessus d'un certain seuil, en-dessous, égal, différent, etc. Il s'agit d'une optimisation qui est utile dans certains scénarios spécifiques. Par exemple, si l'objet a une transparence très élevée, du genre 95%, autant le compter comme complétement transparent, afin d'éviter des opérations de mélange ''alpha''. En effet, les opérations de mélange ''alpha'' sont très lentes, car elles demandent de faire des opérations de lecture-écriture en mémoire vidéo : on lit un pixel dans le ''framebuffer'', on applique le mélange ''alpha'' et on écrit le résultat en mémoire vidéo. L'''alpha test'' permet donc de gagner en performance au prix d'une baisse de la qualité d'image. Il y a cependant des cas où l'usage du test ''alpha'' est primordial, au-delà d'une question de performances. Un exemple classique est celui du rendu du feuillage dans un jeu 3D. Un feuillage est composé en assemblant plusieurs images de feuilles. Chaque feuille est un carré sur lequel on place une texture de feuille, qui est opaque pour la partie verte des feuilles, transparente pour le reste. Les carrés ne sont cependant pas superposés, mais s'intersectent fortement, ce qui fait que le mélange ''alpha'' ne donne pas de bons résultats. L'usage du test ''alpha'' permet d'obtenir un rendu correct. Pour d'informations via ce lien : * [https://bgolus.medium.com/anti-aliased-alpha-test-the-esoteric-alpha-to-coverage-8b177335ae4f Anti-aliased Alpha Test: The Esoteric Alpha To Coverage]. ===Les effets de brouillard=== Les '''effets de brouillard''' sont nécessaires dans certains jeux vidéo pour l'ambiance (pensez à des jeux d'horreur comme Silent Hill), mais ils ont surtout été utilisés pour économiser des calculs. L'idée est de ne pas calculer les graphismes au-delà d'une certaine distance, sans que cela se voie. Le ''view frustum'' utilise alors un plan limite, au-delà duquel on ne voit pas les objets. Mais ce plan limite donne une cassure inesthétique dans le rendu. Pour masquer cette cassure, les programmeurs ajoutaient un effet de brouillard. Les objets au-delà du plan limite étaient totalement dans le brouillard, puis ce brouillard se réduisait progressivement en se rapprochant de la caméra, avant de s'annuler à partir d'une certaine distance. Pour calculer le brouillard, on effectue un mélange ''alpha'' entre la couleur du pixel et une ''couleur de brouillard''. La différence est que l'on n'utilise pas la transparence pour faire le mélange, mais un '''coefficient de brouillard''', noté <math>\text{fog}(z)</math>. : <math>\text{Couleur finale} = \text{fog}(z) \times \text{Couleur de brouillard} + [ 1 - \text{fog}(z) ] \times \text{Couleur du pixel}</math> Le coefficient de brouillard dépend de la coordonnée de profondeur, de la distance du pixel par rapport à la caméra. Le brouillard démarre à une distance <math>z_{fog-start}</math>, et masque totalement les objets à partir d'une distance <math>z_{fog-end}</math>. Entre les deux, le coefficient de brouillard dépend de la distance. OpenGL autorise trois formules de calcul suivantes : : <math>\text{fog}(z) = \frac{z_{fog-end} - z}{z_{fog-end} - z_{fog-start}}</math> : <math>\text{fog}(z) = e^{- k \times z}</math> : <math>\text{fog}(z) = e^{- (k \times z)^2}</math> ==L'éclairage d'une scène 3D== L'éclairage d'une scène 3D calcule les ombres, mais aussi la luminosité de chaque pixel, ainsi que bien d'autres effets graphiques. Les algorithmes d'éclairage ont longtemps été implémentés directement en matériel, les cartes graphiques géraient l'éclairage dans des circuits spécialisés. Aussi, il est important de voir ces algorithmes d'éclairage. Il est possible d'implémenter l'éclairage à deux endroits différents du pipeline : juste avant la rastérisation, et après la rastérisation. ===Les sources de lumière et les couleurs associées=== L'éclairage d'une scène 3D provient de sources de lumières, comme des lampes, des torches, le soleil, etc. Il existe de nombreux types de sources de lumière, et nous n'allons parler que des principales. Elles sont au nombre de quatre et elles sont illustrées ci-dessous. [[File:3udUJ.gif|centre|vignette|upright=2|Types de sources de lumière.]] [[File:Graphics lightmodel directional.png|vignette|upright=1.0|Source de lumière directionnelle.]] Les '''sources directionnelles''' servent à modéliser des sources de lumière très éloignées, comme le soleil ou la lune. Elles sont simplement définies par un vecteur qui indique la direction de la lumière, rien de plus. Les '''sources ponctuelles''' sont des points, qui émettent de la lumière dans toutes les directions. Elles sont définies par une position, et une intensité lumineuse, éventuellement la couleur de la lumière émise. Il existe deyux types de sources de lumière ponctuelles. * Le premières émettent de manière égale dans toutes les directions. Elles sont appelées des ''point light'' dans le schéma du dessus. * Les secondes émettent de la lumière dans une '''direction privilégiée'''. L'exemple le plus parlant est celui d'une lampe-torche : elle émet de la lumière "tout droit", dans la direction où la lampe est orientée. Elles sont appelées des ''sport light'' dans le schéma du dessus. La direction privilégiée est un vecteur, notée v dans le schéma du dessous. [[File:Graphics lightmodel ambient.png|vignette|upright=1.0|Lumière ambiante.]] En théorie, la lumière rebondit sur les surfaces et a tendance à se disperser un peu partout à force de rebondir. C'est ce qui explique qu'on arrive à voir à l'intérieur d'une pièce si une fenêtre est ouverte. Il en résulte un certain '''éclairage ambiant''', qui est assez difficile à représenter dans un moteur de rendu 3D. Auparavant, l'éclairage ambiant était simulé par une lumière égale en tout point de la scène 3D, appelée simplement la '''lumière ambiante'''. Précisément, on suppose que la lumière ambiante en un point vient de toutes les directions et a une intensité constante, identique dans toutes les directions. Le tout est illustré ci-contre. C'est assez irréaliste, mais ça donne une bonne approximation de la lumière ambiante. ===La lumière incidente : le terme géométrique=== Pour simplifier, nous allons supposer que l'éclairage est calculé pour chaque sommet, pas par triangle. C'est de loin le cas le plus courant, aussi ce n'est pas une simplification abusive. La lumière qui arrive sur un sommet est appelée la '''lumière incidente'''. La couleur d'un sommet dépend de deux choses : la lumière incidente directe, comment il réfléchit cette lumière. Mathématiquement, il est possible de résumer cela avec le produit de deux termes : l'intensité de la lumière incidente, une fonction qui indique comment la surface réfléchit la lumière incidente. La fonction en question est appelée la '''réflectivité bidirectionnelle'''. Le terme anglais est ''bidirectional reflectance distribution function'', abrévié en BRDF, et nous utiliserons cette abréviation dans ce qui suit. : <math>\text{Couleur finale} = \text{Lumière incidente} \times BRDF(...)</math> La lumière incidente vient soit directement des sources de lumière, soit de la lumière qui a rebondit sur d'autres objets proches. La première est appelée la lumière directe, celle qui vient des rebonds s'appelle la lumière indirecte. Pour simplifier, la lumière indirecte est gérée par la lumière ambiante, nous passons sous silence les techniques d'illumination globale. En clair : nous allons nous limiter au cas où la lumière incidente vient directement d'une source de lumière, pas d'un rebond. Intuitivement, la lumière incidente est simplement égale à l'intensité de la source de lumière. Sauf que ce n'est qu'une approximation, et une assez mauvaise. En réalité, l'approximation est bonne si la lumière arrive proche de la verticale, mais elle est d'autant plus mauvaise que la lumière arrive penchée, voire rasante. La raison : la lumière incidente sera étalée sur une surface plus grande, si elle arrive penchée. Si vous vous souvenez de vos cours de collège, c'est le même principe qui explique les saisons. La lumière du soleil est proche de la verticale en été, mais est de plus en plus penché quand on s'avance vers l'Hiver. La lumière solaire est donc étalée sur une surface plus grande, ce qui fait qu'un point de la surface recevra moins de lumière, celle-ci étant diluée, étalée. [[File:Radiación solar.png|centre|vignette|upright=2|Exemple avec la lumière solaire.]] [[File:Angle of incidence.svg|vignette|upright=1|Angle d'incidence.]] En clair, tout dépend de l''''angle d'incidence''' de la lumière. Reste à voir comment calculer cet angle. La lumière incidente est définie par un vecteur, qui part de la source de lumière et atterrit sur le sommet considéré. Imaginez simplement que ce vecteur suit un rayon lumineux provenant de la source de lumière. Le vecteur pour la lumière incidente sera noté L. L'angle d'incidence est l'angle que fait ce vecteur avec la verticale de la surface, au niveau du sommet considéré. [[File:Graphics lightmodel ptsource.png|vignette|Normale de la surface.]] Pour cela, les calculs d'éclairage ont besoin de connaitre la verticale d'un sommet. Un sommet est donc associé à un vecteur, appelé la '''normale''', qui indique la verticale en ce point. Deux sommets différents peuvent avoir deux normales différentes, même s'ils sont proches. Elles sont d'autant plus différentes que la surface est rugueuse, non-lisse. La normale est prédéterminée lors de la création du modèle 3D, il n'y a pas besoin de le calculer. Par contre, elle est modifiée lors de l'étape de transformation, quand on place le modèle 3D dans la scène 3D. Les deux autres vecteurs sont à calculer à chaque image, car ils changent quand on bouge le sommet. La lumière qui arrive sur la surface dépend de l'angle entre la normale et le vecteur L. Précisément, elle dépend du cosinus de cet angle. En multipliant ce cosinus avec l'intensité de la lumière, on a la lumière arrivante. La couleur finale d'un pixel est donc : : <math>\text{Couleur finale} = I \times \cos{(N, L)} \times BRDF(...)</math> Le terme <math>I \times \cos{N, L}</math> ne dépend pas de la surface considérée. Juste de la position de la source de lumière, de la position du sommet et de son orientation par rapport à la lumière. Aussi, il est parfois appelé le '''terme géométrique''', en opposition aux propriétés de la surface. Les propriétés de la surface sont définies par un '''''material''''', qui indique comment il réfléchit la lumière, ainsi que sa texture. ===Le produit scalaire de deux vecteurs=== Calculer le terme géométrique demande de calculer le cosinus d'un angle. Et il n'est pas le seul : les autres calculs d'éclairage que nous allons voir demandent de calculer des cosinus. Or, les calculs trigonométriques sont très gourmands pour le GPU. Pour éviter le calcul d'un cosinus, les GPU utilisent une opération mathématique appelée le ''produit scalaire''. Le produit scalaire agit sur deux vecteurs, que l'on notera A et B. Un produit scalaire prend : la longueur des deux vecteurs, et l'angle entre les deux vecteurs noté <math>\omega</math>. Le produit scalaire est équivalent à la formule suivante : : <math>\text{Produit scalaire de deux vecteurs A et B} = \vec{A} \cdot \vec{B} = A \times B \times \cos{(\omega)}</math>, avec A et B la longueur des deux vecteurs A et B. L'avantage est que le produit scalaire se calcule simplement avec des additions, soustractions et multiplications, des opérations que les cartes graphiques savent faire très facilement. Le produit scalaire de deux vecteurs de coordonnées x,y,z est le suivant : : <math>\vec{A} \cdot \vec{B} = x_A \times x_B + y_A \times y_B + z_A \times z_B</math> En clair, on multiplie les coordonnées identiques, et on additionne les résultats. Rien de compliqué. Un avantage est que tous les vecteurs vus précédemment sont normalisés, à savoir qu'ils ont une longueur qui vaut 1. Ainsi, le calcul du produit scalaire devient équivalent au calcul du produit scalaire. ===La réflexion de la lumière sur la surface=== [[File:Ray Diagram 2.svg|vignette|Reflection de la lumière sur une surface parfaitement lisse.]] Maintenant que nous venons de voir le terme géométrique, voyons le BRDF, qui définit comment la surface de l'objet 3D réfléchit la lumière. Vos cours de collège vous ont sans doute appris que la lumière est réfléchie avec le même angle d'arrivée. L'angle d'incidence et l'angle de réflexion sont égaux, comme illustré ci-contre. On parle alors de '''réflexion parfaite'''. Mais cela ne vaut que pour une surface parfaitement lisse, comme un miroir parfait. Dans la réalité, une surface a tendance à renvoyer des rayons dans toutes les directions. La raison est qu'une surface réelle est rugueuse, avec de petites aspérités et des micro-reliefs, qui renvoient la lumière dans des directions "aléatoires". La lumière « rebondit » sur la surface de l'objet et une partie s'éparpille dans un peu toutes les directions. On parle alors de '''réflexion diffuse'''. {| |- |[[File:Dioptre reflexion diffuse speculaire refraction.svg|vignette|upright=1.4|Différence entre réflexion diffuse et spéculaire.]] |[[File:Diffuse reflection.svg|vignette|upright=1|Réflexion diffuse.]] |} Maintenant, imaginons que la surface n'ait qu'une réflexion diffuse, pas d'autres formes de réflexion. Et imaginons aussi que cette réflexion diffuse soit parfaite, à savoir que la lumière réfléchie soit renvoyée à l'identique dans toutes les directions, sans aucune direction privilégiée. On a alors le ''material'' le plus simple qui soit, appelé un '''''diffuse material'''''. Vu que la lumière est réfléchie à l'identique dans toutes les directions, elle sera identique peu importe où on place la caméra. La lumière finale ne dépend donc que des propriété de la surface, que de sa couleur. En clair, il suffit de donner une '''couleur diffuse''' à chaque sommet. La couleur diffuse est simplement multipliée par le terme géométrique, pour obtenir la lumière réfléchie finale. Rien de plus, rien de moins. Cela donne l'équation suivante, avec les termes suivants : * L est le vecteur pour la lumière incidente ; * N est la normale du sommet ; * I est l'intensité de la source de lumière ; * <math>C_d</math> est la couleur diffuse. : <math>\text{Illumination diffuse} = C_d \times \left[ I \times (\vec{N} \cdot \vec{L}) \right]</math> Rajoutons maintenant l'effet de la lumière ambiante à un ''material'' de ce genre. Pour rappel, la lumière ambiante vient de toutes les directions à part égale, ce qui fait que son angle d'incidence n'a donc pas d'effet. L'intensité de la lumière ambiante est déterminée lors de la création de la scène 3D, c'est une constante qui n'a pas à être calculée. Pour obtenir l'effet de la lumière ambiante sur un objet, il suffit de multiplier sa couleur diffuse par l'intensité de la lumière ambiante. Cependant, de nombreux moteurs de jeux ajoutent une '''couleur ambiante''', différente de la couleur diffuse. : <math>\text{Illumination ambiante} = C_a \times I_a</math> avec <math>C_a</math> la couleur ambiante du point de surface et <math>I_a</math> l'intensité de la lumière ambiante. En plus de la réflexion diffuse parfaite, de nombreux matériaux ajoutent une '''réflexion spéculaire''', qui n'est pas exactement la réflexion parfaite, en est très proche. Les rayons réfléchis sont très proches de la direction de réflexion parfaite, et s'atténuent très vite en s'en éloignant. Le résultat ressemble à une sorte de petit "point blanc", très lumineux, orienté vers la source de lumière, appelé le '''''specular highlight'''''. La réflexion diffuse est prédominante pour les matériaux rugueux, alors que la réflexion spéculaire est dominante sur les matériaux métalliques ou très lisses. [[File:Phong components version 4.png|centre|vignette|upright=3.0|Couleurs utilisées dans l'algorithme de Phong.]] [[File:Phong Vectors.svg|vignette|Vecteurs utilisés dans l'algorithme de Phong (et dans le calcul de l'éclairage, de manière générale).]] Pour calculer la réflexion spéculaire, il faut d'abord connaitre le vecteur pour la réflexion parfaite, que nous noterons R dans ce qui suit. Le vecteur R peut se calculer avec la formule ci-dessous : : <math>\vec{R} = 2 (\vec{L} \cdot \vec{N}) \times \vec{N} - \vec{L} </math> La réflexion spéculaire dépend de l'angle entre la direction du regard et la normale : plus celui-ci est proche de l'angle de réflexion parfaite, plus la réflexion spéculaire sera intense. Le vecteur pour la direction du regard sera noté V, pour vue ou vision. La réflexion spéculaire est une fonction qui dépend de l'angle entre les vecteurs R et V. Le calcul de la réflexion spéculaire utilise une '''couleur spéculaire''', qui est l'équivalent de la couleur diffuse pour la réflexion spéculaire. : <math>\text{BRDF spéculaire} = C_s \times f(\vec{R} \cdot \vec{V}) </math> La fonction varie grandement d'un modèle de calcul spéculaire à l'autre. Aussi, je ne rentre pas dans le détail. L'essentiel est que vous compreniez que le calcul de l'éclairage utilise de nombreux calculs géométriques, réalisés avec des produits scalaires. Les calculs géométriques utilisent la couleur d'un sommet, la normale du sommet, et le vecteur de la lumière incidente. Les autres informations sont calculées à l'exécution. ===Les algorithmes d'éclairage basiques : par triangle, par sommet et par pixel=== Dans tout ce qui a été dit précédemment, l'éclairage est calculé pour chaque sommet. Il attribue une illumination/couleur à chaque sommet de la scène 3D, ce qui fait qu'on parle d''''éclairage par sommet''', ou ''vertex lighting''. Il est assez rudimentaire et donne un éclairage très brut, mais il peut être réalisé avant l'étape de rastérisation. Mais une fois qu'on a obtenu la couleur des sommets, reste à colorier les triangles. Et pour cela, il y a deux manières de faire, qui sont appelées l'éclairage plat et l'éclairage de Gouraud. [[File:D3D Shading Triangles.png|vignette|Dans ce dessin, le triangle a un sommet de couleur bleu foncé, un autre de couleur rouge et un autre de couleur bleu clair. L’interpolation plate et de Gouraud donnent des résultats bien différents.]] L''''éclairage plat''' calcule l'éclairage triangle par triangle. Il y a plusieurs manières de faire pour ça, mais la plus simple colorie un triangle avec la couleur moyenne des trois sommets. Une autre possibilité fait les calculs d'éclairage triangle par triangle, en utilisant une normale par triangle et non par sommet, idem pour les couleurs ambiante/spéculaire/diffuse. Mais c'est plus rare car cela demande de placer la normale quelque part dans le triangle, ce qui rajoute des informations. L''''éclairage de Gouraud''' effectue lui aussi une moyenne de la couleur de chaque sommet, sauf que celle-ci est pondérée par la distance du sommet avec le pixel. Plus le pixel est loin d'un sommet, plus son coefficient est petit. Typiquement, le coefficient varie entre 0 et 1 : de 1 si le pixel est sur le sommet, à 0 si le pixel est sur un des sommets adjacents. La moyenne effectuée est généralement une interpolation bilinéaire, mais n'importe quel algorithme d'interpolation peut marcher, qu'il soit simplement linéaire, bilinéaire, cubique, hyperbolique. L'étape d'interpolation est prise en charge par l'étape de rastérisation, qui effectue cette moyenne automatiquement. L'éclairage par sommet a eu son heure de gloire, mais il est maintenant remplacé par l''''éclairage par pixel''' (''per-pixel lighting''), qui calcule l'éclairage pixel par pixel. En clair, l’éclairage est finalisé après l'étape de rastérisation, il ne se fait pas qu'au niveau de la géométrie. Il existe plusieurs types d'éclairage par pixel, mais on peut les classer en deux grands types : l'éclairage de Phong et le ''bump/normal mapping''. L''''éclairage de Phong''' calcule l'éclairage pixel par pixel. Avec cet algorithme, la géométrie n'est pas éclairée : les couleurs des sommets ne sont pas calculées. A la place, les normales sont envoyées à l'étape de rastérisation, qui effectue une opération d'interpolation, qui renvoie une normale pour chaque pixel. Les calculs d'éclairage utilisent alors ces normales pour faire les calculs d'éclairage pour chaque pixel. La technique du '''''normal mapping''''' est assez simple à expliquer, sans compter que plusieurs cartes graphiques l'ont implémentée directement dans leurs circuits. Là où l'éclairage de Phong interpole les normales pour chaque pixel, le ''normal-mapping'' précalcule les normales d'une surface dans une texture, appelée la ''normal-map''. Lors des calculs d'éclairage, la carte graphique lit les normales adéquates directement depuis cette texture, puis fait les calculs d'éclairage avec. [[File:WallSimpleAndNormalMapping.png|centre|vignette|upright=2|Différence sans et avec ''normal-mapping''.]] Avec cette technique, l'éclairage n'est pas géré par pixel, mais par texel, ce qui fait qu'il a une qualité de rendu un peu inférieure à un vrai éclairage de Phong, mais bien supérieure à un éclairage par sommet. Par contre, les techniques de ''normal mapping'' permettent d'ajouter du relief et des détails sur des surfaces planes en jouant sur l'éclairage. Elles permettent ainsi de simplifier grandement la géométrie rendue, tout en utilisant l'éclairage pour compenser. [[File:Bump mapping.png|centre|vignette|upright=2|Bump mapping]] L'éclairage par pixel a une qualité d'éclairage supérieure aux techniques d'éclairage par sommet, mais il est aussi plus gourmand. L'éclairage par pixel est utilisé dans presque tous les jeux vidéo depuis DOOM 3, en raison de sa meilleure qualité, mais cela n'aurait pas été possible si le matériel n'avait pas évolué de manière à incorporer des algorithmes d'éclairage matériel assez puissants, avant de basculer sur un éclairage programmable. La différence entre l'éclairage par pixel et par sommet se voit assez facilement à l'écran. L'éclairage plat donne un éclairage assez carré, avec des frontières assez nettes. L'éclairage de Gouraud donne des ombres plus lisses, dans une certaine mesure, mais pèche à rendre correctement les reflets spéculaires. L'éclairage de Phong est de meilleure qualité, surtout pour les reflets spéculaires. es trois algorithmes peuvent être implémentés soit dans la carte graphique, soit en logiciel. Nous verrons comment les cartes graphiques peuvent implémenter ces algorithmes, dans les deux prochains chapitres. {| |- |[[File:Per face lighting.png|vignette|upright=1|Flat shading]] |[[File:Per vertex lighting.png|vignette|upright=1|Gouraud Shading]] |[[File:Per fragment lighting.png|vignette|upright=1|Phong Shading]] |- |[[File:Per face lighting example.png|vignette|upright=1|Flat shading]] |[[File:Per vertex lighting example.png|vignette|upright=1|Gouraud Shading]] |[[File:Per fragment lighting example.png|vignette|upright=1|Phong Shading]] |} ===Les ''shaders'' : des programmes exécutés sur le GPU=== Maintenant que nous venons de voir les algorithmes d'éclairages, il est temps de voir comment les réaliser sur une carte graphique. Nous venons de voir qu'il y a une différence entre l'éclairage par pixel et par sommet. Intuitivement, l'éclairage par sommet devrait se faire avec les calculs géométriques, alors que l'éclairage par pixel devrait se faire après avoir appliqué les textures. Les toutes premières cartes graphiques ne géraient ni l'éclairage par sommet, ni l'éclairage par pixel. Elles laissaient les calculs géométriques au CPU. Par la suite, la Geforce 256 a intégré '''circuit de ''Transform & Lightning''''', qui s'occupait de tous les calculs géométriques, éclairage par sommet inclus (d'où le L de T&L). Elle gérait alors l'éclairage par sommet, mais un algorithme particulier, qui n'était pas très flexible. Il ne gérait que des ''material'' bien précis (des ''Phong materials''), rien de plus. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Unité de T&L : géométrie | Rastérisation | Placage de textures | ''Raster Operations Pipeline'' |} L'amélioration suivante est venue sur la Geforce 3 : l'unité de T&L est devenue programmable. Au vu le grand nombre d'algorithmes d'éclairages possibles et le grand nombre de ''materials'' possibles, c'était la seule voie possibles. Les programmeurs pouvaient programmer leurs propres algorithmes d'éclairage par sommet, même s'ils devaient aussi programmer les étapes de transformation et de projection. Mais nous détaillerons cela dans un chapitre dédié sur l'historique des GPUs. Ce qui est important est que la Geforce 3 a introduit une fonctionnalité absolument cruciale pour le rendu 3D moderne : les '''''shaders'''''. Il s'agit de programmes informatiques exécutés par la carte graphique, qui servaient initialement à coder des algorithmes d'éclairage. D'où leur nom : ''shader'' pour ''shading'' (éclairage en anglais). Cependant, l'usage modernes des shaders dépasse le cadre des algorithmes d'éclairage. L'avantage est que cela simplifie grandement l'implémentation des algorithmes d'éclairage. Pas besoin de les intégrer dans la carte graphique pour les utiliser, pas besoin d'un circuit distinct pour chaque algorithme. Sans shaders, si la carte graphique ne gère pas un algorithme d'éclairage, on ne peut pas l'utiliser. A la rigueur, il est parfois possible de l'émuler avec des contournements logiciels, mais au prix de performances souvent désastreuses. Avec des shaders, il est possible de programmer l'algorithme d'éclairage de notre choix, pour l'exécuter sur la carte graphique, avec des performances plus que convenables. [[File:Implémentation de l'éclairage sur les cartes graphiques.png|vignette|Implémentation de l'éclairage sur les cartes graphiques]] Il existe plusieurs types de shaders, mais les deux principaux sont les '''''vertex shaders''''' et les '''''pixel shaders'''''. Les pixels shaders s'occupent de l'éclairage par pixel, leur nom est assez parlent. Les vertex shaders s'occupent de l'éclairage par sommet, mais aussi des étapes de transformation/projection. Je parle bien des trois étapes de transformation vues plus haut, qui effectuent des calculs de transformation de coordonnées avec des matrices. La raison à cela est que les calculs de transformation ressemblent beaucoup aux calculs d'éclairage par sommet. Ils impliquent tous deux des calculs vectoriels, comme des produits scalaires et des produits vectoriels, qui agissent sur des sommets/triangles. Si la carte graphique incorpore un processeur de shader capable de faire de tels calculs, alors il peut servir pour les deux. Pour implémenter les shaders, il a fallu ajouter des processeurs à la carte graphique. Les processeurs en question exécutent les shaders, ils peuvent lire ou écrire dans des textures, mais ne font rien d'autres. Les ''vertex shaders'' font tout ce qui a trait à la géométrie, ils remplacent l'unité de T&L. Les pixels shaders sont entre la rastérisation et les ROPs, ils sont très liés à l'unité de texture. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | rowspan="2" class="f_rouge" | ''Vertex shader'' | rowspan="2" | Rastérisation | Placage de textures | rowspan="2" |''Raster Operations Pipeline'' |- | class="f_rouge" | ''Pixel shader'' |} <noinclude> {{NavChapitre | book=Les cartes graphiques | prev=Les cartes d'affichage des anciens PC | prevText=Les cartes d'affichage des anciens PC | next=Avant les GPUs : les cartes accélératrices 3D | nextText=Avant les GPUs : les cartes accélératrices 3D }}{{autocat}} </noinclude> 15wyjrrd4bwxbehe6se6xddstbjtay7 765146 765145 2026-04-26T20:03:51Z Mewtow 31375 /* Le placage de textures direct */ 765146 wikitext text/x-wiki Le premier jeu à utiliser de la "vraie 3D" texturée fut le jeu Quake, premier du nom. Et depuis sa sortie, la grande majorité des jeux vidéo utilisent de la 3D, même s'il existe encore quelques jeux en 2D. Face à la prolifération des jeux vidéo en 3D, les fabricants de cartes graphiques ont inventé les cartes accélératrices 3D, des cartes vidéo capables d'accélérer le rendu en 3D. Dans ce chapitre, nous allons voir comment elles fonctionnent et comment elles ont évolué dans le temps. Pour comprendre comment celles-ci fonctionnent, il faut faire quelques rapides rappels sur les bases du rendu 3D. ==Les bases du rendu 3D== Une '''scène 3D''' est composée d'un espace en trois dimensions, dans laquelle le moteur d’un jeu vidéo place des objets et les fait bouger. Cette scène est, en première approche, un simple parallélogramme. Un des coins de ce parallélogramme sert d’origine à un système de coordonnées : il est à la position (0, 0, 0), et les axes partent de ce point en suivant les arêtes. Les objets seront placés à des coordonnées bien précises dans ce parallélogramme. ===Les objets 3D et leur géométrie=== <noinclude>[[File:Dolphin triangle mesh.png|vignette|Illustration d'un dauphin, représenté avec des triangles.]]</noinclude> Dans la quasi-totalité des jeux vidéo actuels, les objets et la scène 3D sont modélisés par un assemblage de triangles collés les uns aux autres, ce qui porte le nom de '''maillage''', (''mesh'' en anglais). Il a été tenté dans le passé d'utiliser des quadrilatères (rendu dit en ''quad'') ou d'autres polygones, mais les contraintes techniques ont fait que ces solutions n'ont pas été retenues. [[File:CG WIKI.jpg|centre|vignette|upright=2|Exemple de modèle 3D.]] Les modèles 3D sont définis par leurs sommets, aussi appelés '''vertices''' dans le domaine du rendu 3D. Chaque sommet possède trois coordonnées, qui indiquent sa position dans la scène 3D : abscisse, ordonnée, profondeur. Les sommets sont regroupés en triangles, qui sont formés en combinant trois sommets entre eux. Les anciennes cartes graphiques géraient aussi d'autres formes géométriques, comme des points, des lignes, ou des quadrilatères. Les quadrilatères étaient appelés des ''quads'', et ce terme reviendra occasionnellement dans ce cours. De telles formes basiques, gérées nativement, sont appelées des '''primitives'''. La représentation exacte d'un objet est donc une liste plus ou moins structurée de sommets. La liste doit préciser les coordonnées de chaque sommet, ainsi que comment les relier pour former des triangles. Pour cela, l'objet est représenté par une structure qui contient la liste des sommets, mais aussi de quoi savoir quels sont les sommets reliés entre eux par un segment. Nous en dirons plus dans le chapitre sur le rendu de la géométrie. ===La caméra : le point de vue depuis l'écran=== Outre les objets proprement dit, on trouve une '''caméra''', qui représente les yeux du joueur. Cette caméra est définie au minimum par : * une position ; * par la direction du regard (un vecteur). A la caméra, il faut ajouter tout ce qui permet de déterminer le '''champ de vision'''. Le champ de vision contient tout ce qui est visible à l'écran. Et sa forme dépend de la perspective utilisée. Dans le cas le plus courant dans les jeux vidéos en 3D, il correspond à une '''pyramide de vision''' dont la pointe est la caméra, et dont les faces sont délimitées par les bords de l'écran. A l'intérieur de la pyramide, il y a un rectangle qui représente l'écran du joueur, appelé le '''''viewport'''''. <noinclude>[[File:ViewFrustum.jpg|centre|vignette|upright=2|Caméra.]]</noinclude> [[File:ViewFrustum.svg|vignette|upright=1|Volume délimité par la caméra (''view frustum'').]] La majorité des jeux vidéos ajoutent deux plans : * un ''near plane'' en-deça duquel les objets ne sont pas affichés. Il élimine du champ de vision les objets trop proches. * Un ''far plane'', un '''plan limite''' au-delà duquel on ne voit plus les objets. Il élimine les objets trop lointains. Avec ces deux plans, le champ de vision de la caméra est donc un volume en forme de pyramide tronquée, appelé le '''''view frustum'''''. Le tout est parfois appelée, bien que par abus de langage, la pyramide de vision. Avec d'autres perspectives moins utilisées, le ''view frustum'' est un pavé, mais nous n'en parlerons pas plus dans le cadre de ce cours car elles ne sont presque pas utilisés dans les jeux vidéos actuels. ===Les textures=== Tout objet à rendre en 3D est donc composé d'un assemblage de triangles, et ceux-ci sont éclairés et coloriés par divers algorithmes. Pour rajouter de la couleur, les objets sont recouverts par des '''textures''', des images qui servent de papier peint à un objet. Un objet géométrique est donc recouvert par une ou plusieurs textures qui permettent de le colorier ou de lui appliquer du relief. [[File:Texture+Mapping.jpg|centre|vignette|upright=2|Texture Mapping]] Notons que les textures sont des images comme les autres, codées pixel par pixel. Pour faire la différence entre les pixels de l'écran et les pixels d'une texture, on appelle ces derniers des '''texels'''. Ce terme est assez important, aussi profitez-en pour le mémoriser, nous le réutiliserons dans quelques chapitres. Un autre point lié au fait que les textures sont des images est leur compression, leur format. N'allez pas croire que les textures sont stockées dans un fichier .jpg, .png ou tout autre format de ce genre. Les textures utilisent des formats spécialisés, comme le DXTC1, le S3TC ou d'autres, plus adaptés à leur rôle de texture. Mais qu'il s'agisse d'images normales (.jpg, .png ou autres) ou de textures, toutes sont compressées. Les textures sont compressées pour prendre moins de mémoire. Songez que la compression de texture est terriblement efficace, souvent capable de diviser par 6 la mémoire occupée par une texture. S'en est au point où les textures restent compressées sur le disque dur, mais aussi dans la mémoire vidéo ! Nous en reparlerons dans le chapitre sur la mémoire d'une carte graphique. Plaquer une texture sur un objet peut se faire de deux manières, qui portent les noms de placage de texture inverse et direct. Le placage de texture direct a été utilisé au tout début de la 3D, sur des bornes d'arcade et les consoles de jeu 3DO, PS1, Sega Saturn. De nos jours, on utilise uniquement la technique de placage de texture inverse. Les deux seront décrites dans le détail plus bas. ===La différence entre rastérisation et lancer de rayons=== Les techniques de rendu 3D sont nombreuses, mais on peut les classer en deux grands types : le ''lancer de rayons'' et la ''rasterization''. Sans décrire les deux techniques, sachez cependant que le lancer de rayon n'est pas beaucoup utilisé pour les jeux vidéo. Il est surtout utilisé dans la production de films d'animation, d'effets spéciaux, ou d'autres rendu spéciaux. Dans les jeux vidéos, il est surtout utilisé pour quelques effets graphiques, la rasterization restant le mode de rendu principal. La raison principale est que le lancer de rayons demande beaucoup de puissance de calcul. Une autre raison est que créer des cartes accélératrices pour le lancer de rayons n'est pas simple. Il a existé des cartes accélératrices permettant d'accélérer le rendu en lancer de rayons, mais elles sont restées confidentielles. Les cartes graphiques modernes incorporent quelques circuits pour accélérer le lancer de rayons, mais ils restent d'un usage marginal et servent de compléments au rendu par rastérization. Un chapitre entier sera dédié aux cartes accélératrices de lancer de rayons et nous verrons pourquoi le lancer de rayons est difficile à implémenter avec des performances convenables, ce qui explique que les jeux vidéo utilisent la ''rasterization''. La rastérisation est structurée autour de trois étapes principales : * Une étape purement logicielle, effectuée par le processeur, où le moteur physique calcule la géométrie de la scène 3D. * Une étape de '''traitement de la géométrie''', qui gère tout ce qui a trait aux sommets et triangles. * Une étape de '''rastérisation''' qui détermine sur quels pixels de l'écran est affiché le triangle. * Une étape de '''traitement des pixels''', qui colorie les pixels et gère les textures. [[File:Graphics pipeline 2 en.svg|centre|vignette|upright=2.5|Pipeline graphique basique.]] Il existe plusieurs rendus différents et la rastérisation ne se fait pas de la même manière selon le rendu utilisé. Il existe des rendus sans textures, d'autres avec, d'autres avec éclairage, d'autres sans, etc. Par contre, l'étape de calcul de la géométrie est la même quel que soit le rendu ! Mieux : le calcul de la géométrie se fait de la même manière entre rastérisation et lancer de rayons, il est le même quelle que soit la technique de rendu 3D utilisée. Les trois étapes précédentes sont réalisées dans des circuits ou processeurs séparés, comme on le verra plus tard. Et cela permet d'utiliser la technique dite du '''pipeline'''. Concrètement, supposons que la carte graphique traite les données par paquets de triangles (en réalité, c'est des paquets de sommets, mais passons). L'étape de traitement de la géométrie peut travailler sur un paquet de triangle, pendant que le paquet précédent est dans l'étape de rastérisation, et que le paquet encore précédent est en train de traiter ses pixels. Cela permet de traiter trois paquets de triangles en même temps, mais à des états d'avancements différents. Mieux que cela : le traitement de la géométrie est lui-même composé d'une succession de sous-étapes, la rasterisation est elle-même découpée en plusieurs sous-étapes, et ainsi de suite. Le nombre d'étapes pour une carte graphique moderne dépasse la dizaine. La rastérisation calcule un rendu 3D avec une suite d'étapes consécutives qui doivent s'enchainer dans un ordre bien précis. L'ensemble de ces étapes est appelé le '''pipeline graphique''', qui sera détaillé dans ce qui suit. ==Le calcul de la géométrie== Le calcul de la géométrie regroupe plusieurs manipulations différentes. La principale demande juste de placer les modèles 3D dans la scène, de placer les objets dans le monde. Puis, il faut centrer la scène 3D sur la caméra. Les deux changements ont pour point commun de demander des changements de repères. Par changement de repères, on veut dire que l'on passe d'un système de coordonnées à un autre. En tout, il existe trois changements de repères distincts qui sont regroupés dans l''''étape de transformation''' : un premier qui place chaque objet 3D dans la scène 3D, un autre qui centre la scène du point de vue de la caméra, et un autre qui corrige la perspective. ===Les trois étapes de transformation=== La première étape place les objets 3D dans la scène 3D. Un modèle 3D est représentée par un ensemble de sommets, qui sont reliés pour former sa surface. Les données du modèle 3D indiquent, pour chaque sommet, sa position par rapport au centre de l'objet qui a les coordonnées (0, 0, 0). La première étape place l'objet 3D à une position dans la scène 3D, déterminée par le moteur physique, qui a des coordonnées (X, Y, Z). Une fois placé dans la scène 3D, le centre de l'objet passe donc des coordonnées (0, 0, 0) aux coordonnées (X, Y, Z) et tous les sommets de l'objet doivent être mis à jour. De plus, l'objet a une certaine orientation : il faut aussi le faire tourner. Enfin, l'objet peut aussi subir une mise à l'échelle : on peut le gonfler ou le faire rapetisser, du moment que cela ne modifie pas sa forme, mais simplement sa taille. En clair, le modèle 3D subit une translation, une rotation et une mise à l'échelle, les trois impliquant une modification des coordonnées des sommets.. [[File:Similarity and congruence transformations.svg|centre|vignette|upright=1.5|Transformations géométriques possibles pour chaque triangle.]] Une fois le placement des différents objets effectué, la carte graphique effectue un changement de coordonnées pour centrer le monde sur la caméra. Au lieu de considérer un des bords de la scène 3D comme étant le point de coordonnées (0, 0, 0), il va passer dans le référentiel de la caméra. Après cette transformation, le point de coordonnées (0, 0, 0) sera la caméra. La direction de la vue du joueur sera alignée avec l'axe de la profondeur (l'axe Z). [[File:View transform.svg|centre|vignette|upright=2|Étape de transformation dans un environnement en deux dimensions : avant et après. On voit que l'on centre le monde sur la position de la caméra et dans sa direction.]] Enfin, il faut aussi corriger la perspective, ce qui est le fait de l'étape de projection, qui modifie la forme du ''view frustum'' sans en modifier le contenu. Différents types de perspective existent et celles-ci ont un impact différent les unes des autres sur le ''view frustum''. Dans le cas qui nous intéresse, le ''view frustum'' passe d’une forme de trapèze tridimensionnel à une forme de pavé dont l'écran est une des faces. ===Les changements de coordonnées se font via des multiplications de matrices=== Les trois étapes précédentes demande de faire des changements de coordonnées, chaque sommet voyant ses coordonnées remplacées par de nouvelles. Or, un changement de coordonnée s'effectue assez simplement, avec des matrices, à savoir des tableaux organisés en lignes et en colonnes avec un nombre dans chaque case. Un changement de coordonnées se fait simplement en multipliant le vecteur (X, Y, Z) des coordonnées d'un sommet par une matrice adéquate. Il existe des matrices pour la translation, la mise à l'échelle, d'autres pour la rotation, une autre pour la transformation de la caméra, une autre pour l'étape de projection, etc. Un changement de coordonnée s'effectue assez simplement en multipliant le vecteur-coordonnées (X, Y, Z) d'un sommet par une matrice adéquate. Un petit problème est que les matrices qui le permettent sont des matrices avec 4 lignes et 4 colonnes. Or, la multiplication demande que le nombre de coordonnées du vecteur soit égal au nombre de colonnes. Pour résoudre ce petit problème, on ajoute une 4éme coordonnée aux sommets, la coordonnée homogène, qui ne sert à rien, et est souvent mise à 1, par défaut. Mais oublions ce détail. Il se trouve que multiplier des matrices amène certaines simplifications. Au lieu de faire plusieurs multiplications de matrices, il est possible de fusionner les matrices en une seule, ce qui permet de simplifier les calculs. Ce qui fait que le placement des objets, changement de repère pour centrer la caméra, et d'autres traitements forts différents sont regroupés ensemble. Le traitement de la géométrie implique, sans surprise, des calculs de géométrie dans l'espace. Et cela implique des opérations mathématiques aux noms barbares : produits scalaires, produits vectoriels, et autres calculs impliquant des vecteurs et/ou des matrices. Et les calculs vectoriels/matriciels impliquent beaucoup d'additions, de soustractions, de multiplications, de division, mais aussi des opérations plus complexes : calculs trigonométriques, racines carrées, inverse d'une racine carrée, etc. Au final, un simple processeur peut faire ce genre de calculs, si on lui fournit le programme adéquat, l'implémentation est assez aisée. Mais on peut aussi implémenter le tout avec un circuit spécialisé, non-programmable. Les deux solutions sont possibles, tant que le circuit dispose d'assez de puissance de calcul. Les cartes graphiques anciennes contenaient un ou plusieurs circuits de multiplication de matrices spécialisés dans l'étape de transformation. Chacun de ces circuits prend un sommet et renvoie le sommet transformé. Ils sont composés d'un gros paquet de multiplieurs et d'additionneurs flottants. Pour plus d'efficacité, les cartes graphiques comportent plusieurs de ces circuits, afin de pouvoir traiter plusieurs sommets en même temps. ==L'élimination des surfaces cachées== Un point important du rendu 3D est que ce que certaines portions de la scène 3D ne sont pas visibles depuis la caméra. Et idéalement, les portions de la scène 3D qui ne sont pas visibles à l'écran ne doivent pas être calculées. A quoi bon calculer des choses qui ne seront pas affichées ? Ce serait gâcher de la puissance de calcul. Et pour cela, de nombreuses optimisations visent à éliminer les calculs inutiles. Elles sont regroupées sous les termes de '''''clipping''''' ou de '''''culling'''''. La différence entre ''culling'' et ''clipping'' n'est pas fixée et la terminologie n'est pas claire. Dans ce qui va suivre, nous n'utiliserons que le terme ''culling''. Les cartes graphiques modernes embarquent diverses méthodes de ''culling'' pour abandonner les calculs quand elles s’aperçoivent que ceux-ci portent sur une partie non-affichée de l'image. Cela fait des économies de puissance de calcul assez appréciables et un gain en performance assez important. Précisons que le ''culling'' peut être plus ou moins précoce suivant le type de rendu 3D utilisé, mais nous verrons cela dans la suite du chapitre. ===Les différentes formes de ''culling''/''clipping''=== La première forme de ''culling'' est le '''''view frustum culling''''', dont le nom indique qu'il s'agit de l'élimination de tout ce qui est situé en-dehors du ''view frustum''. Ce qui est en-dehors du champ de vision de la caméra n'est pas affiché à l'écran n'est pas calculé ou rendu, dans une certaine mesure. Le ''view frustum culling'' est assez trivial : il suffit d'éliminer ce qui n'est pas dans le ''view frustum'' avec quelques calculs de coordonnées assez simples. Quelques subtilités surviennent quand un triangle est partiellement dans le ''view frustrum'', ce qui arrive parfois si le triangle est sur un bord de l'écran. Mais rien d'insurmontable. [[File:View frustum culling.svg|centre|vignette|upright=1|''View frustum culling'' : les parties potentiellement visibles sont en vert, celles invisibles en rouge et celles partiellement visibles en bleu.]] Les autres formes de ''culling'' visent à éliminer ce qui est dans le ''view frustum'', mais qui n'est pas visible depuis la caméra. Pensez à des objets cachés par un autre objet plus proche, par exemple. Ou encore, pensez aux faces à l'arrière d'un objet opaque qui sont cachées par l'avant. Ces deux cas correspondent à deux types de ''culling''. L'élimination des objets masqués par d'autres est appelé l'''occlusion culling''. L'élimination des parties arrières d'un objet est appelé le ''back-face culling''. Dans les deux cas, nous parlerons d''''élimination des surfaces cachées'''. [[File:Occlusion culling example PL.svg|centre|vignette|''Occlusion culling'' : les objets en bleu sont visibles, ceux en rouge sont masqués par les objets en bleu.]] Le lancer de rayons n'a pas besoin d'éliminer les surfaces cachées, il ne calcule que les surfaces visibles. Par contre, la rastérisation demande d'éliminer les surfaces cachées. Sans cela, le rendu est incorrect dans le pire des cas, ou alors le rendu calcule des surfaces invisibles pour rien. Il existe de nombreux algorithmes logiciels pour implémenter l'élimination des surfaces cachées, mais la carte graphique peut aussi s'en charger. L'''occlusion culling'' demande de connaitre la distance à la caméra de chaque triangle. La distance à la caméra est appelée la '''profondeur''' du triangle. Elle est déterminée à l'étape de rastérisation et est calculée à chaque sommet. Lors de la rastérisation, chaque sommet se voit attribuer trois coordonnées : deux coordonnées x et y qui indiquent sa position à l'écran, et une coordonnée de profondeur notée z. ===L'algorithme du peintre=== Pour éliminer les surfaces cachées, la solution la plus simple consiste simplement à rendre les triangles du plus lointain au plus proche. L'idée est que si deux triangles se recouvrent totalement ou partiellement, on doit dessiner celui qui est derrière, puis celui qui est devant. Le dessin du second va recouvrir le premier. Quelque chose qui devrait vous rappeler le rendu 2D, où les sprites sont rendus du plus lointain au plus proche. Il ne s'agit ni plus ni moins que de l''''algorithme du peintre'''. [[File:Polygons cross.svg|vignette|Polygons cross]] Un problème est que la solution ne marche pas avec certaines configurations particulières, dans le cas où des polygones un peu complexes se chevauchent plusieurs fois. Il se présente rarement dans un rendu 3D normal, mais c'est quand même un cas qu'il faut gérer. Le problème est suffisant pour que cette solution ne soit plus utilisée dans le rendu 3D normal. Un autre problème est que l'algorithme demande de trier les triangles d'une scène 3D selon leur profondeur, du plus profond au moins profond. Et les cartes graphiques n'aiment pas ça, que ce soit les anciennes cartes graphiques comme les modernes. Il s'agit généralement d'une tâche qui est réalisée par le processeur, le CPU, qui est plus efficace que le GPU pour trier des trucs. Aussi, l'algorithme du peintre était utilisé sur d'anciennes cartes graphiques, qui ne géraient pas la géométrie mais seulement les textures et quelques effets de post-processing. Avec ces GPU, les jeux vidéo calculaient la géométrie et la triait sur le CPU, puis effectuaient le reste de la rastérisation sur le GPU. Les anciens jeux en 2.5D comme DOOM ou les DOOM-like, utilisaient une amélioration de l'algorithme du peintre. L'amélioration variait suivant le moteur de jeu utilisé, et donnait soit une technique dite de ''portal rendering'', soit un système de ''Binary Space Partionning'', assez complexes et difficiles à expliquer. Mais il ne s'agissait pas de jeux en 3D, les maps de ces jeux avaient des contraintes qui rendaient cette technique utilisable. Ils n'avaient pas de polygones qui se chevauchent, notamment. ===Le tampon de profondeur=== [[File:Z-buffer no text.jpg|vignette|Z-buffer correspondant à un rendu]] Une autre solution utilise ce qu'on appelle un '''tampon de profondeur''', aussi appelé un ''z-buffer''. Il s'agit d'un tableau, stocké en mémoire vidéo, qui mémorise la coordonnée z de l'objet le plus proche pour chaque pixel. Par défaut, ce tampon de profondeur est initialisé avec la valeur de profondeur maximale, celle du ''far plane'' du ''viewfrustum''. Au fur et à mesure que les objets seront calculés, le tampon de profondeur est mis à jour, conservant ainsi la trace de l'objet le plus proche de la caméra. Si jamais un triangle a une coordonnée z plus grande que celle du tampon de profondeur, cela veut dire qu'il est situé derrière un objet déjà rendu. Il est éliminé (sauf si transparence il y a) et le tampon de profondeur n'a pas à être mis à jour. Dans le cas contraire, l'objet est plus près de la caméra et sa coordonnée z remplace l'ancienne valeur z dans le tampon de profondeur. [[File:Z-buffer.svg|centre|vignette|upright=2.0|Illustration du processus de mise à jour du Z-buffer.]] Il existe des techniques alternatives pour coder la coordonnée de profondeur, qui se distinguent par le fait que la coordonnée z n'est pas proportionnelle à la distance entre le fragment et la caméra. Mais il s'agit là de détails assez mathématiques que je me permets de passer sous silence. Dans la suite de ce cours, nous allons juste parler de profondeur pour regrouper toutes ces techniques, conventionnelles ou alternatives. Toutes les cartes graphiques modernes utilisent un système de ''z-buffer''. C'est la seule solution pour avoir des performances dignes de ce nom. Il faut cependant noter qu'elles utilisent des tampons de profondeur légèrement modifiés, qui ne mémorisent pas la coordonnée de profondeur, mais une valeur dérivée. Pour simplifier, ils ne mémorisent pas la coordonnée de profondeur z, mais son inverse 1/z. Les raisons à cela ne peuvent pas encore être expliquées à ce moment du cours, aussi nous allons simplement dire que c'est une histoire de correction de perspective. Les coordonnées z et 1/z sont codées sur quelques bits, allant de 16 bits pour les anciennes cartes graphiques, à 24/32 bits pour les cartes plus récentes. De nos jours, les Z-buffer de 16 bits sont abandonnés et toutes les cartes graphiques utilisent des coordonnées z de 24 à 32 bits. La raison est que les Z-buffer de 16 bits ont une précision insuffisante, ce qui fait que des artefacts peuvent survenir. Si deux objets sont suffisamment proches, le tampon de profondeur n'a pas la précision suffisante pour discriminer les deux objets. Pour lui, les deux objets sont à la même place. Conséquence : il faut bien choisir un des deux objets et ce choix se fait pixel par pixel, ce qui fait des artefacts visuels apparaissent. On parle alors de '''''z-fighting'''''. Voici ce que cela donne : [[File:Z-fighting.png|centre|vignette|Z-fighting]] Un défaut du tampon de profondeur est qu'il ne gère pas correctement les objets transparents. Dès que de la transparence est présente dans une scène 3D, le tampon de profondeur ne peut pas être utilisé. Une solution pour cela est de rendre une scène 3D en deux phases : une pour les objets opaques, une avec les objets transparents. La où on rend les objets opaques utilise le tampon de profondeur, mais il est désactivé lors de la seconde. ==La rastérisation et les textures== Dans cette section, nous allons voir ensemble l'étape de rastérisation et l'étape de traitement des pixels. La rastérisation est difficile à expliquer, surtout que son rôle exact dépend de la technique de rendu utilisée. Pour simplifier, elle projette un rendu en 3D sur un écran en 2D. Une autre explication tout aussi vague est qu'elle s'occupe la traduction des triangles en un affichage pixelisé à l'écran. Elle détermine à quoi ressemble la scène visible sur l'écran. C'est par exemple lors de cette étape que sont appliquées certaines techniques de ''culling'', qui éliminent les portions non-visibles de l'image, ainsi qu'une correction de la perspective et diverses opérations d'interpolation dont nous parlerons dans plusieurs chapitres. La rastérisation et placage de textures sont deux opérations très liées entre elles. Il existe deux manières principales pour lier les textures à la géométrie : la méthode directe et la méthode inverse (''UV Mapping''). Et les deux font que la rastérisation se fait de manière très différente. Précisons cependant que les rendus les plus simples n'utilisent pas de textures du tout. Ils se contentent de colorier les triangles, voire d'un simple rendu en fil de fer basé sur du tracé de lignes. Dans la suite de cette section, nous allons voir les quatre types de rendu principaux : le rendu en fils de fer, le rendu colorié, et deux rendus utilisant des textures. ===Le rendu en fil de fer=== [[File:Obj lineremoval.png|vignette|Rendu en fil de fer d'un objet 3D.]] Le '''rendu 3D en fils de fer''' est illustré ci-contre. Il s'agit d'un rendu assez ancien, utilisé au tout début de la 3D, sur des machines qu'on aurait du mal à appeler ordinateurs. Il se contente de tracer des lignes à l'écran, lignes qui connectent deux sommets, qui ne sont autres que les arêtes de la géométrie de la scène rendue. Le tout était suffisant pour réaliser quelques jeux vidéos rudimentaires. Les tout premiers jeux vidéos utilisaient ce rendu, l'un d'entre eux étant Maze War, le tout premier FPS. {| |[[File:Maze war.jpg|vignette|Maze war]] |[[File:Maze representation using wireframes 2022-01-10.gif|centre|vignette|Maze representation using wireframes 2022-01-10]] |} Le monde est calculé en 3D, il y a toujours un calcul de la géométrie, la scène est rastérisée normalement, les portions invisbles de l'image sont retirées, mais il n'y a pas d'application de textures après rastérisation. A la place, un algorithme de tracé de ligne trace les lignes à l'écran. Quand un triangle passe l'étape de rastérisation, l'étape de rastérisation fournit la position des trois sommets sur l'écran. En clair, elle fournit les coordonnées de trois pixels, un par sommet. A la suite, un algorithme de tracé de ligne trace trois lignes, une par paire de sommet. L'implémentation demande juste d'avoir une unité de calcul géométrique, une unité de rastérisation, et un VDC qui supporte le tracé de lignes. Elle est donc assez simple et ne demande pas de circuits de gestion des textures ni de ROP. Le VDC écrit directement dans le ''framebuffer'' les lignes à tracer. Il a existé des proto-cartes graphiques spécialisées dans ce genre de rendu, comme le '''''Line Drawing System-1''''' de l'entreprise Eans & Sutherland. Nous détaillerons son fonctionnement dans quelques chapitres. ===Le rendu à primitives colorées=== [[File:MiniFighter.png|vignette|upright=1|Exemple de rendu pouvant être obtenu avec des sommets colorés.]] Une amélioration du rendu précédent utilise des triangles/''quads'' coloriés. Chaque triangle ou ''quad'' est associé à une couleur, et cette couleur est dessinée sur le triangle/''quad''après la rastérisation. Le rendu est une amélioration du rendu en fils de fer. L'idée est que chaque triangle/''quad'' est associé à une couleur, qui est dessinée sur le triangle/''quad'' après la rastérisation. La technique est nommée ''colored vertices'' en anglais, nous parlerons de '''rendu à maillage coloré'''. [[File:Malla irregular de triángulos modelizando una superficie convexa.png|centre|vignette|upright=2|Maillage coloré.]] La couleur est propagée lors des calculs géométriques et de la rastérisation, sans subir de modifications. Une fois un rendu en fils de fer effectué, la couleur du triangle est récupérée. Le triangle/''quad'' rendu correspond à un triangle/''quad'' à l'écran. Et l'intérieur de ce triangle/''quad'' est colorié avec la couleur transmise. Pour cela, on utilise encore une fois une fonction du VDC : celle du remplissage de figure géométrique. Nous l’avions vu en parlant des VDC à accélération 2D, mais elle est souvent prise en charge par les ''blitters''. Ils peuvent remplir une figure géométrique avec une couleur unique, on réutilise cette fonction pour colorier le triangle/''quad''. L'étape de rastérisation fournit les coordonnées des sommets de la figure géométrique, le ''blitter'' les utilise pour colorier la figure géométrique. Niveau matériel, quelques bornes d'arcade ont utilisé ce rendu. La toute première borne d'arcade utilisant le rendu à maillage coloré est celle du jeu I Robot, d'Atari, sorti en 1983. Par la suite, dès 1988, les cartes d'arcades Namco System 21 et les bornes d'arcades Sega Model 1 utilisaient ce genre de rendu. On peut s'en rendre compte en regardant les graphismes des jeux tournant sur ces bornes d'arcade. Des jeux comme Virtua Racing, Virtua Fighter ou Virtua Formula sont assez parlants à ce niveau. Leurs graphismes sont assez anguleux et on voit qu'ils sont basés sur des triangles uniformément colorés. Pour ceux qui veulent en savoir plus sur la toute première borne d'arcade en rendu à maillage colorée, la borne ''I Robot'' d'Atari, voici une vidéo youtube à ce sujet : * [https://www.youtube.com/watch?v=6miEkPENsT0 I Robot d'Atari, le pionnier de la 3D Flat.] ===Le placage de textures direct=== Les deux rendus précédents sont très simples, mais n'utilisent pas de textures. Et il est temps de voir les deux rendus qui utilisent des textures. Il y en a deux types, appelés rendu avec placage de texture direct et indirect, nous allons voir le '''rendu par placage de texture direct''' en premier. Et nous l'appellerons ''rendu direct'' dans ce qui suit, pour simplifier les explications. L'idée est assez simple et peut utiliser aussi bien des triangles que des ''quads'', mais nous allons partir du principe qu'elle utilise des '''''quads''''', à savoir que les objets 3D sont composés de quadrilatères. Lorsqu'un ''quad'' est rastérisé, sa forme à l'écran est un rectangle déformé par la perspective. On obtient un rectangle si le ''quad'' est vu de face, un trapèze si on le voit de biais. Et le ''sprite'' doit être déformé de la même manière que le ''quad''. L'idée est que tout quad est associé à une texture, à un sprite. La figure géométrique qui correspond à un ''quad'' à l'écran est remplie non pas par une couleur uniforme, mais par un ''sprite'' rectangulaire. Il suffit techniquement de recopier le ''sprite'' à l'écran, c'est à dire dans la figure géométrique, au bon endroit dans le ''framebuffer''. Le rendu direct est en effet un intermédiaire entre rendu 2D à base de ''sprite'' et rendu 3D moderne. La géométrie est rendue en 3D pour générer des ''quads'', mais ces ''quads'' ne servent à guider la copie des sprites/textures dans le ''framebuffer''. [[File:TextureMapping.png|centre|vignette|upright=2|Exemple caricatural de placage de texture sur un ''quad''.]] La subtilité est que le sprite est déformé de manière à rentrer dans un quadrilatère, qui n'est pas forcément un rectangle à l'écran, mais est déformé par la perspective et son orientation en 3D. Le sprite doit être déformé de deux manières : il doit être agrandi/réduit en fonction de la taille de la figure affichée à l'écran, tourné en fonction de l'orientation du ''quad'', déformé pour gérer la perspective. Pour cela, il faut connaitre les coordonnées de profondeur de chaque bord d'un ''quad'', et de faire quelques calculs. N'importe quel VDC incluant un ''blitter'' avec une gestion du zoom/rotation des sprites peut le faire. : Si on veut avoir de beaux graphismes, il vaut mieux appliquer un filtre pour lisser le sprite envoyé dans le trapèze, filtre qui se résume à une opération d'interpolation et n'est pas très différent du filtrage de texture qui lisse les textures à l'écran. Un autre point est que les ''quads'' doivent être rendus du plus lointain au plus proche. Sans cela, on obtient rapidement des erreurs de rendu. L'idée est que si deux quads se chevauchent, on doit dessiner celui qui est derrière, puis celui qui est devant. Le dessin du second va recouvrir le premier. L'écriture du sprite du second quad écrasera les données du premier quad, pour les portions recouvertes, lors de l'écriture du sprite dans le ''framebuffer''. Quelque chose qui devrait vous rappeler le rendu 2D, où les sprites sont rendus du plus lointain au plus proche. Le rendu inverse utilise très souvent des triangles pour la géométrie, alors que le rendu direct a tendance à utiliser des ''quads'', mais il ne s'agit pas d'une différence stricte. L'usage de triangles/''quads'' peut se faire aussi bien avec un rendu direct comme avec un rendu inverse. Cependant, le rendu en ''quad'' se marie très bien au rendu direct, alors que le rendu en triangle colle mieux au rendu inverse. L'avantage de cette technique est qu'on parcourt les textures dans un ordre bien précis. Par exemple, on peut parcourir la texture ligne par ligne, l'exploiter par blocs de 4*4 pixels, etc. Et accéder à une texture de manière prédictible se marie bien avec l'usage de mémoires caches, ce qui est un avantage en matière de performances. Mais un même pixel du ''framebuffer'' est écrit plusieurs fois quand plusieurs quads se superposent, alors que le rendu inverse gère la situation avec une seule écriture (sauf si usage de la transparence). De plus, la gestion de la transparence était compliquée et les jeux devaient ruser en utilisation des solutions logicielles assez complexes. Niveau implémentation matérielle, une carte graphique en rendu direct demande juste trois circuits. Le premier est un circuit de calcul géométrique, qui rend la scène 3D. Le tri des quads est souvent réalisé par le processeur principal, et non pas par un circuit séparé. Toutes les étapes au-delà de l'étape de rastérisation étaient prises en charge par un VDC amélioré, qui écrivait des sprites/textures directement dans le ''framebuffer''. {|class="wikitable" |- ! Géométrie | Processeurs dédiés programmé pour émuler le pipeline graphique |- ! Tri des quads du plus lointain au plus proche | Processeur principal (implémentation logicielle) |- ! Application des textures | ''Blitter'' amélioré, capable de faire tourner et de zoomer sur des ''sprites''. |} L'implémentation était très simple et réutilisait des composants déjà existants : des VDC 2D pour l'application des textures, des processeurs dédiés pour la géométrie. Les unités de calcul de la géométrie étaient généralement implémentées avec un ou plusieurs processeurs dédiés. Vu qu'on savait déjà effectuer le rendu géométrique en logiciel, pas besoin de créer un circuit sur mesure. Il suffisait de dédier un processeur spécialisé rien que pour les calculs géométriques et on lui faisait exécuter un code déjà bien connu à la base. En clair, ils utilisaient un code spécifique pour émuler un circuit fixe. C'était clairement la solution la plus adaptée pour l'époque. Les unités géométriques étaient des processeurs RISC, normalement utilisés dans l'embarqué ou sur des serveurs. Elles utilisaient parfois des DSP. Pour rappel, les DSP des processeurs de traitement de signal assez communs, pas spécialement dédiés aux rendu 3D, mais spécialisé dans le traitement de signal audio, vidéo et autre. Ils avaient un jeu d'instruction assez proche de celui des cartes graphiques actuelles, et supportaient de nombreuses instructions utiles pour le rendu 3D. Le rendu direct a été utilisé dans la période de transition entre rendu 2D et rendu 3D, car il était très adapté pour faire cette transition. Coupler un VDC à un processeur pour la géométrie était particulièrement simple à l'époque. Le rendu direct a été utilisé sur des bornes d'arcade dès les années 90. Outre les bornes d'arcade, quelques consoles de 5ème génération utilisaient le rendu direct, avec les mêmes solutions matérielles. La géométrie était calculée sur plusieurs processeurs dédiés. Le reste du pipeline était géré par un VDC 2D qui implémentait le placage de textures. Deux consoles étaient dans ce cas : la 3DO, et la Sega Saturn. Le rendu direct est aujourd'hui abandonné. ===Le placage de textures inverse=== Le rendu précédent, le rendu direct, permet d'appliquer des textures directement dans le ''framebuffer''. Mais comme dit plus haut, il existe une seconde technique pour plaquer des textures, appelé le '''placage de texture inverse''', aussi appelé l'''UV Mapping''. Elle associe une texture complète pour un modèle 3D,contrairement au placage de tecture direct qui associe une texture par ''quad''/triangle. L'idée est que l'on attribue un texel à chaque sommet. Plus précisémment, chaque sommet est associé à des '''coordonnées de texture''', qui précisent quelle texture appliquer, mais aussi où se situe le texel à appliquer dans la texture. Par exemple, la coordonnée de texture peut dire : je veux le pixel qui est à ligne 5, colonne 27 dans cette texture. La correspondance entre texture et géométrie est réalisée lorsque les créateurs de jeu vidéo conçoivent le modèle de l'objet. [[File:Texture Mapping example.png|centre|vignette|upright=2|Exemple de placage de texture.]] Dans les faits, on n'utilise pas de coordonnées entières de ce type, mais deux nombres flottants compris entre 0 et 1. La coordonnée 0,0 correspond au texel en bas à gauche, celui de coordonnée 1,1 est tout en haut à droite. L'avantage est que ces coordonnées sont indépendantes de la résolution de la texture, ce qui aura des avantages pour certaines techniques de rendu, comme le ''mip-mapping''. Les deux coordonnées de texture sont notées u,v avec DirectX, ou encore s,t dans le cas général : u est la coordonnée horizontale, v la verticale. [[File:UVMapping.png|centre|vignette|upright=2|UV Mapping]] Avec le placage de texture inverse, la rastérisation se fait grosso-modo en trois étapes : la rastérisation proprement dite, le placage de textures, et les opérations finales qui écrivent un pixel dans le ''framebuffer''. Au niveau du matériel, ainsi que dans la plupart des API 3D, les trois étapes sont réalisées par des circuits séparés. [[File:01 3D-Rasterung-a.svg|vignette|Illustration du principe de la rasterization. La surface correspondant à l'écran est subdivisée en pixels carrés, de coordonnées x et y. La caméra est placée au point e. Pour chaque pixel, on trace une droite qui part de la caméra et qui passe par le pixel considéré. L'intersection entre une surface et cette droite se fait en un point, appartenant à un triangle.]] Lors de la rasterisation, chaque triangle se voit attribuer un ou plusieurs pixels à l'écran. Pour bien comprendre, imaginez une ligne droite qui part de caméra et qui passe par un pixel sur le plan de l'écran. Cette ligne intersecte 0, 1 ou plusieurs objets dans la scène 3D. Les triangles situés ces intersections entre cette ligne et les objets rencontrés seront associés au pixel correspondant. L'étape de rastérisation prend en entrée un triangle et renvoie la coordonnée x,y du pixel associé. Il s'agit là d'une simplification, car un triangle tend à occuper plusieurs pixels sur l'écran. L'étape de rastérisation fournit la liste de tous les pixels occupés par un triangle, et les traite un par un. Quand un triangle est rastérisé, le rasteriseur détermine la coordonnée x,y du premier pixel, applique une texture dessus, puis passe au suivant, et rebelote jusqu'à ce que tous les pixels occupés par le triangles aient été traités. L'implémentation matérielle du placage de texture inverse est beaucoup plus complexe que pour les autres techniques. Pour être franc, nous allons passer le reste du cours à parler de l'implémentation matérielle du placage de texture inverse, ce qui prendra plus d'une dizaine de chapitres. ==La transparence, les fragments et les ROPs== Dans ce qui suit, nous allons parler uniquement de la rastérisation avec placage de textures inverse. Les autres formes de rastérisation ne seront pas abordées. La raison est que tous les GPUs modernes utilisent cette forme de rastérisation, les exceptions étant rares. De même, ils utilisent un tampon de profondeur, pour l'élimination des surfaces cachées. La rastérisation effectue donc des calculs géométriques, suivis d'une étape de rastérisation, puis de placage des textures. Ces trois étapes sont réalisées par une unité géométrique, une unité de rastérisation, et un circuit de placage de textures. Du moins sur le principe, car les cartes graphiques modernes ont fortement optimisé l'implémentation et n'ont pas hésité à fusionner certains circuits. Mais nous verrons cela en temps voulu, nous n'allons pas résumer plusieurs décennies d'innovation technologique en quelques paragraphes. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Placage de textures |} Mais où mettre le tampon de profondeur ? Intuitivement, on se dit qu'il vaut mieux faire l'élimination des surfaces cachées le plus tôt possible, dès que la coordonnée de profondeur est connue. Et elle est connu à l'étape de rastérisation, une fois les sommets transformés. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Tampon de profondeur | Placage de textures |} En réalité, la profondeur des fragments est gérée par un circuit appelé le '''''Raster Operations Pipeline''''' (ROP), situé à la toute fin du pipeline graphique. Dans ce qui suit, nous utiliserons l'abréviation ROP pour simplifier les explications. Le ROP effectue quelques traitements sur les fragments, avant d'enregistrer l'image finale dans la mémoire vidéo. Il est placé à la fin du pipeline pour gérer correctement la transparence. Et nous allons voir pourquoi la transparence est gérée à la fin du pipeline. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Placage de textures | ''Raster Operations Pipeline'' |} ===Le mélange ''alpha''=== La transparence se manifeste quand plusieurs objets sont l'un derrière l'autre. Histoire de simplifier les explications, nous allons d'abord voir le cas où un objet semi-transparent est devant un objet opaque. La couleur perçue est alors un mélange de la couleur de l'objet opaque et celle de l'objet semi-transparent. Le mélange dépend d'à quel point l'objet semi-transparent est transparent. Avec un objet parfaitement transparent, seul l'objet opaque est visible. Avec un objet à moitié transparent, la couleur finale sera pour moitié celle de l'objet opaque, pour moitié celle de l'objet semi-transparent. Et c'est pareil pour les cas intermédiaires entre un objet totalement transparent et un objet totalement opaque. La transparence d'un objet/pixel est définie par un nombre, appelé la '''composante ''alpha'''''. Plus la composante alpha est élevée, plus le pixel est opaque. Elle vaut 0 pour un objet opaque et 1 pour un objet transparent. Elle est ajoutée aux composantes RGB, ce qui fait que tout fragment contient une "couleur de transparence" en plus des couleurs RGB. Elle agit comme un coefficient qui dit comment mélanger la couleur d'un objet transparent et d'un objet opaque. Le calcul de la transparence est une moyenne pondérée par la composante alpha. On parle alors d''''''alpha blending'''''. : <math>\text{Couleur finale} = \alpha \times \text{Couleur de l'objet transparent} + (1 - \alpha) \times \text{Couleur de l'objet opaque}</math> [[File:Texture splatting.png|centre|vignette|upright=2.0|Calcul de transparence. La première ligne montre le produit pour l'objet transparent, la seconde ligne est celle de l'objet opaque. La troisième ligne est celle de l'addition finale.]] Maintenant, qu'en est-il du cas où plusieurs objets sont superposés ? Si vous tracez une demi-droite dont l'origine est la caméra et qui passe par le pixel, il arrive qu'elle intersecte la géométrie en plusieurs points, un point par objet sur la ligne du regarde. Sans transparence, l'objet le plus proche cache tous les autres et c'est donc lui qui décide de la couleur du pixel. Mais avec un objet transparent, la couleur finale est un mélange de la couleur de plusieurs points d'intersection. Il faut donc calculer un pseudo-pixel pour chaque point d'intersection, auquel on donne le nom de '''fragment'''. Un fragment possède une position à l'écran, une coordonnée de profondeur, une couleur, ainsi que quelques autres informations potentiellement utiles. Les fragments attribués à un même pixel, qui sont à la même position sur l'écran, sont combinés pour obtenir la couleur finale de ce pixel. Il est possible d'utiliser le mélange ''alpha'' pour cela. Il suffit de faire le mélange ''alpha'' entre le fragment qui vient d'être calculé, et le pixel dans le ''framebuffer''. Pour cela, le fragment a une composante ''alpha'', qui est ajouté aux trois couleurs RGB. Le pixel déjà dans le ''framebuffer'' est un résultat temporaire, né du mélange ''alpha'' de tous les fragments précédents. Un défaut de cette méthode est qu'elle fonctionne assez mal avec un tampon de profondeur. Si le tampon de profondeur est activé, le mélange ''alpha'' ne fonctionne que si les objets sont rendus du plus lointain au plus proche. Et procéder dans cet ordre a un défaut : on dessine des objets dans le ''framebuffer'', pour qu'ensuite les objets devant écrasent ce qui a déjà été dessiné. Un même pixel peut donc être dessiné plusieurs fois, dont une seule sera pertinente. Et ces écritures utilisent de la bande passante mémoire, qui est une ressource précieuse sur un GPU moderne. Il s'agit d'un phénomène appelé '''''overdraw''''', ou sur-dessinage en français. Quelques optimisations permettent d'éliminer l'''overdraw'' en rendant les objets du plus proche au plus lointain, d'autres permettent de dessiner des objets dans un ordre arbitraire, mais nous ne pouvons pas en parler ici. Beaucoup de moteurs 3D rendent séparément les objets opaques et transparents. Une première passe rend les objets opaques, puis les objets transparents sont rendus dans une seconde passe. Les objets opaques sont rendus dans le désordre, ce qui fait qu'on n'a pas à les trier, alors que les objets transparents doivent être triés selon leur distance. un autre avantage est que le mélange ''alpha'' est désactivé lors de la première passe, alors que c'est la mise à jour du tampon de profondeur qui est désactivé lors de la seconde passe, ce qui augmente un peu les performances dans les deux cas. ===Le test ''alpha''=== Le test ''alpha'' est une technique qui permet d'annuler le rendu d'un fragment en fonction de sa transparence. Si la composante alpha est en-dessous ou au-dessus d'un seuil, le fragment est simplement abandonné. Le seuil en question est configurable, de même que la comparaison utilisée : on peut éliminer le fragment si sa transparence est au-dessus d'un certain seuil, en-dessous, égal, différent, etc. Il s'agit d'une optimisation qui est utile dans certains scénarios spécifiques. Par exemple, si l'objet a une transparence très élevée, du genre 95%, autant le compter comme complétement transparent, afin d'éviter des opérations de mélange ''alpha''. En effet, les opérations de mélange ''alpha'' sont très lentes, car elles demandent de faire des opérations de lecture-écriture en mémoire vidéo : on lit un pixel dans le ''framebuffer'', on applique le mélange ''alpha'' et on écrit le résultat en mémoire vidéo. L'''alpha test'' permet donc de gagner en performance au prix d'une baisse de la qualité d'image. Il y a cependant des cas où l'usage du test ''alpha'' est primordial, au-delà d'une question de performances. Un exemple classique est celui du rendu du feuillage dans un jeu 3D. Un feuillage est composé en assemblant plusieurs images de feuilles. Chaque feuille est un carré sur lequel on place une texture de feuille, qui est opaque pour la partie verte des feuilles, transparente pour le reste. Les carrés ne sont cependant pas superposés, mais s'intersectent fortement, ce qui fait que le mélange ''alpha'' ne donne pas de bons résultats. L'usage du test ''alpha'' permet d'obtenir un rendu correct. Pour d'informations via ce lien : * [https://bgolus.medium.com/anti-aliased-alpha-test-the-esoteric-alpha-to-coverage-8b177335ae4f Anti-aliased Alpha Test: The Esoteric Alpha To Coverage]. ===Les effets de brouillard=== Les '''effets de brouillard''' sont nécessaires dans certains jeux vidéo pour l'ambiance (pensez à des jeux d'horreur comme Silent Hill), mais ils ont surtout été utilisés pour économiser des calculs. L'idée est de ne pas calculer les graphismes au-delà d'une certaine distance, sans que cela se voie. Le ''view frustum'' utilise alors un plan limite, au-delà duquel on ne voit pas les objets. Mais ce plan limite donne une cassure inesthétique dans le rendu. Pour masquer cette cassure, les programmeurs ajoutaient un effet de brouillard. Les objets au-delà du plan limite étaient totalement dans le brouillard, puis ce brouillard se réduisait progressivement en se rapprochant de la caméra, avant de s'annuler à partir d'une certaine distance. Pour calculer le brouillard, on effectue un mélange ''alpha'' entre la couleur du pixel et une ''couleur de brouillard''. La différence est que l'on n'utilise pas la transparence pour faire le mélange, mais un '''coefficient de brouillard''', noté <math>\text{fog}(z)</math>. : <math>\text{Couleur finale} = \text{fog}(z) \times \text{Couleur de brouillard} + [ 1 - \text{fog}(z) ] \times \text{Couleur du pixel}</math> Le coefficient de brouillard dépend de la coordonnée de profondeur, de la distance du pixel par rapport à la caméra. Le brouillard démarre à une distance <math>z_{fog-start}</math>, et masque totalement les objets à partir d'une distance <math>z_{fog-end}</math>. Entre les deux, le coefficient de brouillard dépend de la distance. OpenGL autorise trois formules de calcul suivantes : : <math>\text{fog}(z) = \frac{z_{fog-end} - z}{z_{fog-end} - z_{fog-start}}</math> : <math>\text{fog}(z) = e^{- k \times z}</math> : <math>\text{fog}(z) = e^{- (k \times z)^2}</math> ==L'éclairage d'une scène 3D== L'éclairage d'une scène 3D calcule les ombres, mais aussi la luminosité de chaque pixel, ainsi que bien d'autres effets graphiques. Les algorithmes d'éclairage ont longtemps été implémentés directement en matériel, les cartes graphiques géraient l'éclairage dans des circuits spécialisés. Aussi, il est important de voir ces algorithmes d'éclairage. Il est possible d'implémenter l'éclairage à deux endroits différents du pipeline : juste avant la rastérisation, et après la rastérisation. ===Les sources de lumière et les couleurs associées=== L'éclairage d'une scène 3D provient de sources de lumières, comme des lampes, des torches, le soleil, etc. Il existe de nombreux types de sources de lumière, et nous n'allons parler que des principales. Elles sont au nombre de quatre et elles sont illustrées ci-dessous. [[File:3udUJ.gif|centre|vignette|upright=2|Types de sources de lumière.]] [[File:Graphics lightmodel directional.png|vignette|upright=1.0|Source de lumière directionnelle.]] Les '''sources directionnelles''' servent à modéliser des sources de lumière très éloignées, comme le soleil ou la lune. Elles sont simplement définies par un vecteur qui indique la direction de la lumière, rien de plus. Les '''sources ponctuelles''' sont des points, qui émettent de la lumière dans toutes les directions. Elles sont définies par une position, et une intensité lumineuse, éventuellement la couleur de la lumière émise. Il existe deyux types de sources de lumière ponctuelles. * Le premières émettent de manière égale dans toutes les directions. Elles sont appelées des ''point light'' dans le schéma du dessus. * Les secondes émettent de la lumière dans une '''direction privilégiée'''. L'exemple le plus parlant est celui d'une lampe-torche : elle émet de la lumière "tout droit", dans la direction où la lampe est orientée. Elles sont appelées des ''sport light'' dans le schéma du dessus. La direction privilégiée est un vecteur, notée v dans le schéma du dessous. [[File:Graphics lightmodel ambient.png|vignette|upright=1.0|Lumière ambiante.]] En théorie, la lumière rebondit sur les surfaces et a tendance à se disperser un peu partout à force de rebondir. C'est ce qui explique qu'on arrive à voir à l'intérieur d'une pièce si une fenêtre est ouverte. Il en résulte un certain '''éclairage ambiant''', qui est assez difficile à représenter dans un moteur de rendu 3D. Auparavant, l'éclairage ambiant était simulé par une lumière égale en tout point de la scène 3D, appelée simplement la '''lumière ambiante'''. Précisément, on suppose que la lumière ambiante en un point vient de toutes les directions et a une intensité constante, identique dans toutes les directions. Le tout est illustré ci-contre. C'est assez irréaliste, mais ça donne une bonne approximation de la lumière ambiante. ===La lumière incidente : le terme géométrique=== Pour simplifier, nous allons supposer que l'éclairage est calculé pour chaque sommet, pas par triangle. C'est de loin le cas le plus courant, aussi ce n'est pas une simplification abusive. La lumière qui arrive sur un sommet est appelée la '''lumière incidente'''. La couleur d'un sommet dépend de deux choses : la lumière incidente directe, comment il réfléchit cette lumière. Mathématiquement, il est possible de résumer cela avec le produit de deux termes : l'intensité de la lumière incidente, une fonction qui indique comment la surface réfléchit la lumière incidente. La fonction en question est appelée la '''réflectivité bidirectionnelle'''. Le terme anglais est ''bidirectional reflectance distribution function'', abrévié en BRDF, et nous utiliserons cette abréviation dans ce qui suit. : <math>\text{Couleur finale} = \text{Lumière incidente} \times BRDF(...)</math> La lumière incidente vient soit directement des sources de lumière, soit de la lumière qui a rebondit sur d'autres objets proches. La première est appelée la lumière directe, celle qui vient des rebonds s'appelle la lumière indirecte. Pour simplifier, la lumière indirecte est gérée par la lumière ambiante, nous passons sous silence les techniques d'illumination globale. En clair : nous allons nous limiter au cas où la lumière incidente vient directement d'une source de lumière, pas d'un rebond. Intuitivement, la lumière incidente est simplement égale à l'intensité de la source de lumière. Sauf que ce n'est qu'une approximation, et une assez mauvaise. En réalité, l'approximation est bonne si la lumière arrive proche de la verticale, mais elle est d'autant plus mauvaise que la lumière arrive penchée, voire rasante. La raison : la lumière incidente sera étalée sur une surface plus grande, si elle arrive penchée. Si vous vous souvenez de vos cours de collège, c'est le même principe qui explique les saisons. La lumière du soleil est proche de la verticale en été, mais est de plus en plus penché quand on s'avance vers l'Hiver. La lumière solaire est donc étalée sur une surface plus grande, ce qui fait qu'un point de la surface recevra moins de lumière, celle-ci étant diluée, étalée. [[File:Radiación solar.png|centre|vignette|upright=2|Exemple avec la lumière solaire.]] [[File:Angle of incidence.svg|vignette|upright=1|Angle d'incidence.]] En clair, tout dépend de l''''angle d'incidence''' de la lumière. Reste à voir comment calculer cet angle. La lumière incidente est définie par un vecteur, qui part de la source de lumière et atterrit sur le sommet considéré. Imaginez simplement que ce vecteur suit un rayon lumineux provenant de la source de lumière. Le vecteur pour la lumière incidente sera noté L. L'angle d'incidence est l'angle que fait ce vecteur avec la verticale de la surface, au niveau du sommet considéré. [[File:Graphics lightmodel ptsource.png|vignette|Normale de la surface.]] Pour cela, les calculs d'éclairage ont besoin de connaitre la verticale d'un sommet. Un sommet est donc associé à un vecteur, appelé la '''normale''', qui indique la verticale en ce point. Deux sommets différents peuvent avoir deux normales différentes, même s'ils sont proches. Elles sont d'autant plus différentes que la surface est rugueuse, non-lisse. La normale est prédéterminée lors de la création du modèle 3D, il n'y a pas besoin de le calculer. Par contre, elle est modifiée lors de l'étape de transformation, quand on place le modèle 3D dans la scène 3D. Les deux autres vecteurs sont à calculer à chaque image, car ils changent quand on bouge le sommet. La lumière qui arrive sur la surface dépend de l'angle entre la normale et le vecteur L. Précisément, elle dépend du cosinus de cet angle. En multipliant ce cosinus avec l'intensité de la lumière, on a la lumière arrivante. La couleur finale d'un pixel est donc : : <math>\text{Couleur finale} = I \times \cos{(N, L)} \times BRDF(...)</math> Le terme <math>I \times \cos{N, L}</math> ne dépend pas de la surface considérée. Juste de la position de la source de lumière, de la position du sommet et de son orientation par rapport à la lumière. Aussi, il est parfois appelé le '''terme géométrique''', en opposition aux propriétés de la surface. Les propriétés de la surface sont définies par un '''''material''''', qui indique comment il réfléchit la lumière, ainsi que sa texture. ===Le produit scalaire de deux vecteurs=== Calculer le terme géométrique demande de calculer le cosinus d'un angle. Et il n'est pas le seul : les autres calculs d'éclairage que nous allons voir demandent de calculer des cosinus. Or, les calculs trigonométriques sont très gourmands pour le GPU. Pour éviter le calcul d'un cosinus, les GPU utilisent une opération mathématique appelée le ''produit scalaire''. Le produit scalaire agit sur deux vecteurs, que l'on notera A et B. Un produit scalaire prend : la longueur des deux vecteurs, et l'angle entre les deux vecteurs noté <math>\omega</math>. Le produit scalaire est équivalent à la formule suivante : : <math>\text{Produit scalaire de deux vecteurs A et B} = \vec{A} \cdot \vec{B} = A \times B \times \cos{(\omega)}</math>, avec A et B la longueur des deux vecteurs A et B. L'avantage est que le produit scalaire se calcule simplement avec des additions, soustractions et multiplications, des opérations que les cartes graphiques savent faire très facilement. Le produit scalaire de deux vecteurs de coordonnées x,y,z est le suivant : : <math>\vec{A} \cdot \vec{B} = x_A \times x_B + y_A \times y_B + z_A \times z_B</math> En clair, on multiplie les coordonnées identiques, et on additionne les résultats. Rien de compliqué. Un avantage est que tous les vecteurs vus précédemment sont normalisés, à savoir qu'ils ont une longueur qui vaut 1. Ainsi, le calcul du produit scalaire devient équivalent au calcul du produit scalaire. ===La réflexion de la lumière sur la surface=== [[File:Ray Diagram 2.svg|vignette|Reflection de la lumière sur une surface parfaitement lisse.]] Maintenant que nous venons de voir le terme géométrique, voyons le BRDF, qui définit comment la surface de l'objet 3D réfléchit la lumière. Vos cours de collège vous ont sans doute appris que la lumière est réfléchie avec le même angle d'arrivée. L'angle d'incidence et l'angle de réflexion sont égaux, comme illustré ci-contre. On parle alors de '''réflexion parfaite'''. Mais cela ne vaut que pour une surface parfaitement lisse, comme un miroir parfait. Dans la réalité, une surface a tendance à renvoyer des rayons dans toutes les directions. La raison est qu'une surface réelle est rugueuse, avec de petites aspérités et des micro-reliefs, qui renvoient la lumière dans des directions "aléatoires". La lumière « rebondit » sur la surface de l'objet et une partie s'éparpille dans un peu toutes les directions. On parle alors de '''réflexion diffuse'''. {| |- |[[File:Dioptre reflexion diffuse speculaire refraction.svg|vignette|upright=1.4|Différence entre réflexion diffuse et spéculaire.]] |[[File:Diffuse reflection.svg|vignette|upright=1|Réflexion diffuse.]] |} Maintenant, imaginons que la surface n'ait qu'une réflexion diffuse, pas d'autres formes de réflexion. Et imaginons aussi que cette réflexion diffuse soit parfaite, à savoir que la lumière réfléchie soit renvoyée à l'identique dans toutes les directions, sans aucune direction privilégiée. On a alors le ''material'' le plus simple qui soit, appelé un '''''diffuse material'''''. Vu que la lumière est réfléchie à l'identique dans toutes les directions, elle sera identique peu importe où on place la caméra. La lumière finale ne dépend donc que des propriété de la surface, que de sa couleur. En clair, il suffit de donner une '''couleur diffuse''' à chaque sommet. La couleur diffuse est simplement multipliée par le terme géométrique, pour obtenir la lumière réfléchie finale. Rien de plus, rien de moins. Cela donne l'équation suivante, avec les termes suivants : * L est le vecteur pour la lumière incidente ; * N est la normale du sommet ; * I est l'intensité de la source de lumière ; * <math>C_d</math> est la couleur diffuse. : <math>\text{Illumination diffuse} = C_d \times \left[ I \times (\vec{N} \cdot \vec{L}) \right]</math> Rajoutons maintenant l'effet de la lumière ambiante à un ''material'' de ce genre. Pour rappel, la lumière ambiante vient de toutes les directions à part égale, ce qui fait que son angle d'incidence n'a donc pas d'effet. L'intensité de la lumière ambiante est déterminée lors de la création de la scène 3D, c'est une constante qui n'a pas à être calculée. Pour obtenir l'effet de la lumière ambiante sur un objet, il suffit de multiplier sa couleur diffuse par l'intensité de la lumière ambiante. Cependant, de nombreux moteurs de jeux ajoutent une '''couleur ambiante''', différente de la couleur diffuse. : <math>\text{Illumination ambiante} = C_a \times I_a</math> avec <math>C_a</math> la couleur ambiante du point de surface et <math>I_a</math> l'intensité de la lumière ambiante. En plus de la réflexion diffuse parfaite, de nombreux matériaux ajoutent une '''réflexion spéculaire''', qui n'est pas exactement la réflexion parfaite, en est très proche. Les rayons réfléchis sont très proches de la direction de réflexion parfaite, et s'atténuent très vite en s'en éloignant. Le résultat ressemble à une sorte de petit "point blanc", très lumineux, orienté vers la source de lumière, appelé le '''''specular highlight'''''. La réflexion diffuse est prédominante pour les matériaux rugueux, alors que la réflexion spéculaire est dominante sur les matériaux métalliques ou très lisses. [[File:Phong components version 4.png|centre|vignette|upright=3.0|Couleurs utilisées dans l'algorithme de Phong.]] [[File:Phong Vectors.svg|vignette|Vecteurs utilisés dans l'algorithme de Phong (et dans le calcul de l'éclairage, de manière générale).]] Pour calculer la réflexion spéculaire, il faut d'abord connaitre le vecteur pour la réflexion parfaite, que nous noterons R dans ce qui suit. Le vecteur R peut se calculer avec la formule ci-dessous : : <math>\vec{R} = 2 (\vec{L} \cdot \vec{N}) \times \vec{N} - \vec{L} </math> La réflexion spéculaire dépend de l'angle entre la direction du regard et la normale : plus celui-ci est proche de l'angle de réflexion parfaite, plus la réflexion spéculaire sera intense. Le vecteur pour la direction du regard sera noté V, pour vue ou vision. La réflexion spéculaire est une fonction qui dépend de l'angle entre les vecteurs R et V. Le calcul de la réflexion spéculaire utilise une '''couleur spéculaire''', qui est l'équivalent de la couleur diffuse pour la réflexion spéculaire. : <math>\text{BRDF spéculaire} = C_s \times f(\vec{R} \cdot \vec{V}) </math> La fonction varie grandement d'un modèle de calcul spéculaire à l'autre. Aussi, je ne rentre pas dans le détail. L'essentiel est que vous compreniez que le calcul de l'éclairage utilise de nombreux calculs géométriques, réalisés avec des produits scalaires. Les calculs géométriques utilisent la couleur d'un sommet, la normale du sommet, et le vecteur de la lumière incidente. Les autres informations sont calculées à l'exécution. ===Les algorithmes d'éclairage basiques : par triangle, par sommet et par pixel=== Dans tout ce qui a été dit précédemment, l'éclairage est calculé pour chaque sommet. Il attribue une illumination/couleur à chaque sommet de la scène 3D, ce qui fait qu'on parle d''''éclairage par sommet''', ou ''vertex lighting''. Il est assez rudimentaire et donne un éclairage très brut, mais il peut être réalisé avant l'étape de rastérisation. Mais une fois qu'on a obtenu la couleur des sommets, reste à colorier les triangles. Et pour cela, il y a deux manières de faire, qui sont appelées l'éclairage plat et l'éclairage de Gouraud. [[File:D3D Shading Triangles.png|vignette|Dans ce dessin, le triangle a un sommet de couleur bleu foncé, un autre de couleur rouge et un autre de couleur bleu clair. L’interpolation plate et de Gouraud donnent des résultats bien différents.]] L''''éclairage plat''' calcule l'éclairage triangle par triangle. Il y a plusieurs manières de faire pour ça, mais la plus simple colorie un triangle avec la couleur moyenne des trois sommets. Une autre possibilité fait les calculs d'éclairage triangle par triangle, en utilisant une normale par triangle et non par sommet, idem pour les couleurs ambiante/spéculaire/diffuse. Mais c'est plus rare car cela demande de placer la normale quelque part dans le triangle, ce qui rajoute des informations. L''''éclairage de Gouraud''' effectue lui aussi une moyenne de la couleur de chaque sommet, sauf que celle-ci est pondérée par la distance du sommet avec le pixel. Plus le pixel est loin d'un sommet, plus son coefficient est petit. Typiquement, le coefficient varie entre 0 et 1 : de 1 si le pixel est sur le sommet, à 0 si le pixel est sur un des sommets adjacents. La moyenne effectuée est généralement une interpolation bilinéaire, mais n'importe quel algorithme d'interpolation peut marcher, qu'il soit simplement linéaire, bilinéaire, cubique, hyperbolique. L'étape d'interpolation est prise en charge par l'étape de rastérisation, qui effectue cette moyenne automatiquement. L'éclairage par sommet a eu son heure de gloire, mais il est maintenant remplacé par l''''éclairage par pixel''' (''per-pixel lighting''), qui calcule l'éclairage pixel par pixel. En clair, l’éclairage est finalisé après l'étape de rastérisation, il ne se fait pas qu'au niveau de la géométrie. Il existe plusieurs types d'éclairage par pixel, mais on peut les classer en deux grands types : l'éclairage de Phong et le ''bump/normal mapping''. L''''éclairage de Phong''' calcule l'éclairage pixel par pixel. Avec cet algorithme, la géométrie n'est pas éclairée : les couleurs des sommets ne sont pas calculées. A la place, les normales sont envoyées à l'étape de rastérisation, qui effectue une opération d'interpolation, qui renvoie une normale pour chaque pixel. Les calculs d'éclairage utilisent alors ces normales pour faire les calculs d'éclairage pour chaque pixel. La technique du '''''normal mapping''''' est assez simple à expliquer, sans compter que plusieurs cartes graphiques l'ont implémentée directement dans leurs circuits. Là où l'éclairage de Phong interpole les normales pour chaque pixel, le ''normal-mapping'' précalcule les normales d'une surface dans une texture, appelée la ''normal-map''. Lors des calculs d'éclairage, la carte graphique lit les normales adéquates directement depuis cette texture, puis fait les calculs d'éclairage avec. [[File:WallSimpleAndNormalMapping.png|centre|vignette|upright=2|Différence sans et avec ''normal-mapping''.]] Avec cette technique, l'éclairage n'est pas géré par pixel, mais par texel, ce qui fait qu'il a une qualité de rendu un peu inférieure à un vrai éclairage de Phong, mais bien supérieure à un éclairage par sommet. Par contre, les techniques de ''normal mapping'' permettent d'ajouter du relief et des détails sur des surfaces planes en jouant sur l'éclairage. Elles permettent ainsi de simplifier grandement la géométrie rendue, tout en utilisant l'éclairage pour compenser. [[File:Bump mapping.png|centre|vignette|upright=2|Bump mapping]] L'éclairage par pixel a une qualité d'éclairage supérieure aux techniques d'éclairage par sommet, mais il est aussi plus gourmand. L'éclairage par pixel est utilisé dans presque tous les jeux vidéo depuis DOOM 3, en raison de sa meilleure qualité, mais cela n'aurait pas été possible si le matériel n'avait pas évolué de manière à incorporer des algorithmes d'éclairage matériel assez puissants, avant de basculer sur un éclairage programmable. La différence entre l'éclairage par pixel et par sommet se voit assez facilement à l'écran. L'éclairage plat donne un éclairage assez carré, avec des frontières assez nettes. L'éclairage de Gouraud donne des ombres plus lisses, dans une certaine mesure, mais pèche à rendre correctement les reflets spéculaires. L'éclairage de Phong est de meilleure qualité, surtout pour les reflets spéculaires. es trois algorithmes peuvent être implémentés soit dans la carte graphique, soit en logiciel. Nous verrons comment les cartes graphiques peuvent implémenter ces algorithmes, dans les deux prochains chapitres. {| |- |[[File:Per face lighting.png|vignette|upright=1|Flat shading]] |[[File:Per vertex lighting.png|vignette|upright=1|Gouraud Shading]] |[[File:Per fragment lighting.png|vignette|upright=1|Phong Shading]] |- |[[File:Per face lighting example.png|vignette|upright=1|Flat shading]] |[[File:Per vertex lighting example.png|vignette|upright=1|Gouraud Shading]] |[[File:Per fragment lighting example.png|vignette|upright=1|Phong Shading]] |} ===Les ''shaders'' : des programmes exécutés sur le GPU=== Maintenant que nous venons de voir les algorithmes d'éclairages, il est temps de voir comment les réaliser sur une carte graphique. Nous venons de voir qu'il y a une différence entre l'éclairage par pixel et par sommet. Intuitivement, l'éclairage par sommet devrait se faire avec les calculs géométriques, alors que l'éclairage par pixel devrait se faire après avoir appliqué les textures. Les toutes premières cartes graphiques ne géraient ni l'éclairage par sommet, ni l'éclairage par pixel. Elles laissaient les calculs géométriques au CPU. Par la suite, la Geforce 256 a intégré '''circuit de ''Transform & Lightning''''', qui s'occupait de tous les calculs géométriques, éclairage par sommet inclus (d'où le L de T&L). Elle gérait alors l'éclairage par sommet, mais un algorithme particulier, qui n'était pas très flexible. Il ne gérait que des ''material'' bien précis (des ''Phong materials''), rien de plus. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Unité de T&L : géométrie | Rastérisation | Placage de textures | ''Raster Operations Pipeline'' |} L'amélioration suivante est venue sur la Geforce 3 : l'unité de T&L est devenue programmable. Au vu le grand nombre d'algorithmes d'éclairages possibles et le grand nombre de ''materials'' possibles, c'était la seule voie possibles. Les programmeurs pouvaient programmer leurs propres algorithmes d'éclairage par sommet, même s'ils devaient aussi programmer les étapes de transformation et de projection. Mais nous détaillerons cela dans un chapitre dédié sur l'historique des GPUs. Ce qui est important est que la Geforce 3 a introduit une fonctionnalité absolument cruciale pour le rendu 3D moderne : les '''''shaders'''''. Il s'agit de programmes informatiques exécutés par la carte graphique, qui servaient initialement à coder des algorithmes d'éclairage. D'où leur nom : ''shader'' pour ''shading'' (éclairage en anglais). Cependant, l'usage modernes des shaders dépasse le cadre des algorithmes d'éclairage. L'avantage est que cela simplifie grandement l'implémentation des algorithmes d'éclairage. Pas besoin de les intégrer dans la carte graphique pour les utiliser, pas besoin d'un circuit distinct pour chaque algorithme. Sans shaders, si la carte graphique ne gère pas un algorithme d'éclairage, on ne peut pas l'utiliser. A la rigueur, il est parfois possible de l'émuler avec des contournements logiciels, mais au prix de performances souvent désastreuses. Avec des shaders, il est possible de programmer l'algorithme d'éclairage de notre choix, pour l'exécuter sur la carte graphique, avec des performances plus que convenables. [[File:Implémentation de l'éclairage sur les cartes graphiques.png|vignette|Implémentation de l'éclairage sur les cartes graphiques]] Il existe plusieurs types de shaders, mais les deux principaux sont les '''''vertex shaders''''' et les '''''pixel shaders'''''. Les pixels shaders s'occupent de l'éclairage par pixel, leur nom est assez parlent. Les vertex shaders s'occupent de l'éclairage par sommet, mais aussi des étapes de transformation/projection. Je parle bien des trois étapes de transformation vues plus haut, qui effectuent des calculs de transformation de coordonnées avec des matrices. La raison à cela est que les calculs de transformation ressemblent beaucoup aux calculs d'éclairage par sommet. Ils impliquent tous deux des calculs vectoriels, comme des produits scalaires et des produits vectoriels, qui agissent sur des sommets/triangles. Si la carte graphique incorpore un processeur de shader capable de faire de tels calculs, alors il peut servir pour les deux. Pour implémenter les shaders, il a fallu ajouter des processeurs à la carte graphique. Les processeurs en question exécutent les shaders, ils peuvent lire ou écrire dans des textures, mais ne font rien d'autres. Les ''vertex shaders'' font tout ce qui a trait à la géométrie, ils remplacent l'unité de T&L. Les pixels shaders sont entre la rastérisation et les ROPs, ils sont très liés à l'unité de texture. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | rowspan="2" class="f_rouge" | ''Vertex shader'' | rowspan="2" | Rastérisation | Placage de textures | rowspan="2" |''Raster Operations Pipeline'' |- | class="f_rouge" | ''Pixel shader'' |} <noinclude> {{NavChapitre | book=Les cartes graphiques | prev=Les cartes d'affichage des anciens PC | prevText=Les cartes d'affichage des anciens PC | next=Avant les GPUs : les cartes accélératrices 3D | nextText=Avant les GPUs : les cartes accélératrices 3D }}{{autocat}} </noinclude> ex7gbqvoijj7omc115jk0gcm52nwyr8 765147 765146 2026-04-26T20:06:48Z Mewtow 31375 /* Les algorithmes d'éclairage basiques : par triangle, par sommet et par pixel */ 765147 wikitext text/x-wiki Le premier jeu à utiliser de la "vraie 3D" texturée fut le jeu Quake, premier du nom. Et depuis sa sortie, la grande majorité des jeux vidéo utilisent de la 3D, même s'il existe encore quelques jeux en 2D. Face à la prolifération des jeux vidéo en 3D, les fabricants de cartes graphiques ont inventé les cartes accélératrices 3D, des cartes vidéo capables d'accélérer le rendu en 3D. Dans ce chapitre, nous allons voir comment elles fonctionnent et comment elles ont évolué dans le temps. Pour comprendre comment celles-ci fonctionnent, il faut faire quelques rapides rappels sur les bases du rendu 3D. ==Les bases du rendu 3D== Une '''scène 3D''' est composée d'un espace en trois dimensions, dans laquelle le moteur d’un jeu vidéo place des objets et les fait bouger. Cette scène est, en première approche, un simple parallélogramme. Un des coins de ce parallélogramme sert d’origine à un système de coordonnées : il est à la position (0, 0, 0), et les axes partent de ce point en suivant les arêtes. Les objets seront placés à des coordonnées bien précises dans ce parallélogramme. ===Les objets 3D et leur géométrie=== <noinclude>[[File:Dolphin triangle mesh.png|vignette|Illustration d'un dauphin, représenté avec des triangles.]]</noinclude> Dans la quasi-totalité des jeux vidéo actuels, les objets et la scène 3D sont modélisés par un assemblage de triangles collés les uns aux autres, ce qui porte le nom de '''maillage''', (''mesh'' en anglais). Il a été tenté dans le passé d'utiliser des quadrilatères (rendu dit en ''quad'') ou d'autres polygones, mais les contraintes techniques ont fait que ces solutions n'ont pas été retenues. [[File:CG WIKI.jpg|centre|vignette|upright=2|Exemple de modèle 3D.]] Les modèles 3D sont définis par leurs sommets, aussi appelés '''vertices''' dans le domaine du rendu 3D. Chaque sommet possède trois coordonnées, qui indiquent sa position dans la scène 3D : abscisse, ordonnée, profondeur. Les sommets sont regroupés en triangles, qui sont formés en combinant trois sommets entre eux. Les anciennes cartes graphiques géraient aussi d'autres formes géométriques, comme des points, des lignes, ou des quadrilatères. Les quadrilatères étaient appelés des ''quads'', et ce terme reviendra occasionnellement dans ce cours. De telles formes basiques, gérées nativement, sont appelées des '''primitives'''. La représentation exacte d'un objet est donc une liste plus ou moins structurée de sommets. La liste doit préciser les coordonnées de chaque sommet, ainsi que comment les relier pour former des triangles. Pour cela, l'objet est représenté par une structure qui contient la liste des sommets, mais aussi de quoi savoir quels sont les sommets reliés entre eux par un segment. Nous en dirons plus dans le chapitre sur le rendu de la géométrie. ===La caméra : le point de vue depuis l'écran=== Outre les objets proprement dit, on trouve une '''caméra''', qui représente les yeux du joueur. Cette caméra est définie au minimum par : * une position ; * par la direction du regard (un vecteur). A la caméra, il faut ajouter tout ce qui permet de déterminer le '''champ de vision'''. Le champ de vision contient tout ce qui est visible à l'écran. Et sa forme dépend de la perspective utilisée. Dans le cas le plus courant dans les jeux vidéos en 3D, il correspond à une '''pyramide de vision''' dont la pointe est la caméra, et dont les faces sont délimitées par les bords de l'écran. A l'intérieur de la pyramide, il y a un rectangle qui représente l'écran du joueur, appelé le '''''viewport'''''. <noinclude>[[File:ViewFrustum.jpg|centre|vignette|upright=2|Caméra.]]</noinclude> [[File:ViewFrustum.svg|vignette|upright=1|Volume délimité par la caméra (''view frustum'').]] La majorité des jeux vidéos ajoutent deux plans : * un ''near plane'' en-deça duquel les objets ne sont pas affichés. Il élimine du champ de vision les objets trop proches. * Un ''far plane'', un '''plan limite''' au-delà duquel on ne voit plus les objets. Il élimine les objets trop lointains. Avec ces deux plans, le champ de vision de la caméra est donc un volume en forme de pyramide tronquée, appelé le '''''view frustum'''''. Le tout est parfois appelée, bien que par abus de langage, la pyramide de vision. Avec d'autres perspectives moins utilisées, le ''view frustum'' est un pavé, mais nous n'en parlerons pas plus dans le cadre de ce cours car elles ne sont presque pas utilisés dans les jeux vidéos actuels. ===Les textures=== Tout objet à rendre en 3D est donc composé d'un assemblage de triangles, et ceux-ci sont éclairés et coloriés par divers algorithmes. Pour rajouter de la couleur, les objets sont recouverts par des '''textures''', des images qui servent de papier peint à un objet. Un objet géométrique est donc recouvert par une ou plusieurs textures qui permettent de le colorier ou de lui appliquer du relief. [[File:Texture+Mapping.jpg|centre|vignette|upright=2|Texture Mapping]] Notons que les textures sont des images comme les autres, codées pixel par pixel. Pour faire la différence entre les pixels de l'écran et les pixels d'une texture, on appelle ces derniers des '''texels'''. Ce terme est assez important, aussi profitez-en pour le mémoriser, nous le réutiliserons dans quelques chapitres. Un autre point lié au fait que les textures sont des images est leur compression, leur format. N'allez pas croire que les textures sont stockées dans un fichier .jpg, .png ou tout autre format de ce genre. Les textures utilisent des formats spécialisés, comme le DXTC1, le S3TC ou d'autres, plus adaptés à leur rôle de texture. Mais qu'il s'agisse d'images normales (.jpg, .png ou autres) ou de textures, toutes sont compressées. Les textures sont compressées pour prendre moins de mémoire. Songez que la compression de texture est terriblement efficace, souvent capable de diviser par 6 la mémoire occupée par une texture. S'en est au point où les textures restent compressées sur le disque dur, mais aussi dans la mémoire vidéo ! Nous en reparlerons dans le chapitre sur la mémoire d'une carte graphique. Plaquer une texture sur un objet peut se faire de deux manières, qui portent les noms de placage de texture inverse et direct. Le placage de texture direct a été utilisé au tout début de la 3D, sur des bornes d'arcade et les consoles de jeu 3DO, PS1, Sega Saturn. De nos jours, on utilise uniquement la technique de placage de texture inverse. Les deux seront décrites dans le détail plus bas. ===La différence entre rastérisation et lancer de rayons=== Les techniques de rendu 3D sont nombreuses, mais on peut les classer en deux grands types : le ''lancer de rayons'' et la ''rasterization''. Sans décrire les deux techniques, sachez cependant que le lancer de rayon n'est pas beaucoup utilisé pour les jeux vidéo. Il est surtout utilisé dans la production de films d'animation, d'effets spéciaux, ou d'autres rendu spéciaux. Dans les jeux vidéos, il est surtout utilisé pour quelques effets graphiques, la rasterization restant le mode de rendu principal. La raison principale est que le lancer de rayons demande beaucoup de puissance de calcul. Une autre raison est que créer des cartes accélératrices pour le lancer de rayons n'est pas simple. Il a existé des cartes accélératrices permettant d'accélérer le rendu en lancer de rayons, mais elles sont restées confidentielles. Les cartes graphiques modernes incorporent quelques circuits pour accélérer le lancer de rayons, mais ils restent d'un usage marginal et servent de compléments au rendu par rastérization. Un chapitre entier sera dédié aux cartes accélératrices de lancer de rayons et nous verrons pourquoi le lancer de rayons est difficile à implémenter avec des performances convenables, ce qui explique que les jeux vidéo utilisent la ''rasterization''. La rastérisation est structurée autour de trois étapes principales : * Une étape purement logicielle, effectuée par le processeur, où le moteur physique calcule la géométrie de la scène 3D. * Une étape de '''traitement de la géométrie''', qui gère tout ce qui a trait aux sommets et triangles. * Une étape de '''rastérisation''' qui détermine sur quels pixels de l'écran est affiché le triangle. * Une étape de '''traitement des pixels''', qui colorie les pixels et gère les textures. [[File:Graphics pipeline 2 en.svg|centre|vignette|upright=2.5|Pipeline graphique basique.]] Il existe plusieurs rendus différents et la rastérisation ne se fait pas de la même manière selon le rendu utilisé. Il existe des rendus sans textures, d'autres avec, d'autres avec éclairage, d'autres sans, etc. Par contre, l'étape de calcul de la géométrie est la même quel que soit le rendu ! Mieux : le calcul de la géométrie se fait de la même manière entre rastérisation et lancer de rayons, il est le même quelle que soit la technique de rendu 3D utilisée. Les trois étapes précédentes sont réalisées dans des circuits ou processeurs séparés, comme on le verra plus tard. Et cela permet d'utiliser la technique dite du '''pipeline'''. Concrètement, supposons que la carte graphique traite les données par paquets de triangles (en réalité, c'est des paquets de sommets, mais passons). L'étape de traitement de la géométrie peut travailler sur un paquet de triangle, pendant que le paquet précédent est dans l'étape de rastérisation, et que le paquet encore précédent est en train de traiter ses pixels. Cela permet de traiter trois paquets de triangles en même temps, mais à des états d'avancements différents. Mieux que cela : le traitement de la géométrie est lui-même composé d'une succession de sous-étapes, la rasterisation est elle-même découpée en plusieurs sous-étapes, et ainsi de suite. Le nombre d'étapes pour une carte graphique moderne dépasse la dizaine. La rastérisation calcule un rendu 3D avec une suite d'étapes consécutives qui doivent s'enchainer dans un ordre bien précis. L'ensemble de ces étapes est appelé le '''pipeline graphique''', qui sera détaillé dans ce qui suit. ==Le calcul de la géométrie== Le calcul de la géométrie regroupe plusieurs manipulations différentes. La principale demande juste de placer les modèles 3D dans la scène, de placer les objets dans le monde. Puis, il faut centrer la scène 3D sur la caméra. Les deux changements ont pour point commun de demander des changements de repères. Par changement de repères, on veut dire que l'on passe d'un système de coordonnées à un autre. En tout, il existe trois changements de repères distincts qui sont regroupés dans l''''étape de transformation''' : un premier qui place chaque objet 3D dans la scène 3D, un autre qui centre la scène du point de vue de la caméra, et un autre qui corrige la perspective. ===Les trois étapes de transformation=== La première étape place les objets 3D dans la scène 3D. Un modèle 3D est représentée par un ensemble de sommets, qui sont reliés pour former sa surface. Les données du modèle 3D indiquent, pour chaque sommet, sa position par rapport au centre de l'objet qui a les coordonnées (0, 0, 0). La première étape place l'objet 3D à une position dans la scène 3D, déterminée par le moteur physique, qui a des coordonnées (X, Y, Z). Une fois placé dans la scène 3D, le centre de l'objet passe donc des coordonnées (0, 0, 0) aux coordonnées (X, Y, Z) et tous les sommets de l'objet doivent être mis à jour. De plus, l'objet a une certaine orientation : il faut aussi le faire tourner. Enfin, l'objet peut aussi subir une mise à l'échelle : on peut le gonfler ou le faire rapetisser, du moment que cela ne modifie pas sa forme, mais simplement sa taille. En clair, le modèle 3D subit une translation, une rotation et une mise à l'échelle, les trois impliquant une modification des coordonnées des sommets.. [[File:Similarity and congruence transformations.svg|centre|vignette|upright=1.5|Transformations géométriques possibles pour chaque triangle.]] Une fois le placement des différents objets effectué, la carte graphique effectue un changement de coordonnées pour centrer le monde sur la caméra. Au lieu de considérer un des bords de la scène 3D comme étant le point de coordonnées (0, 0, 0), il va passer dans le référentiel de la caméra. Après cette transformation, le point de coordonnées (0, 0, 0) sera la caméra. La direction de la vue du joueur sera alignée avec l'axe de la profondeur (l'axe Z). [[File:View transform.svg|centre|vignette|upright=2|Étape de transformation dans un environnement en deux dimensions : avant et après. On voit que l'on centre le monde sur la position de la caméra et dans sa direction.]] Enfin, il faut aussi corriger la perspective, ce qui est le fait de l'étape de projection, qui modifie la forme du ''view frustum'' sans en modifier le contenu. Différents types de perspective existent et celles-ci ont un impact différent les unes des autres sur le ''view frustum''. Dans le cas qui nous intéresse, le ''view frustum'' passe d’une forme de trapèze tridimensionnel à une forme de pavé dont l'écran est une des faces. ===Les changements de coordonnées se font via des multiplications de matrices=== Les trois étapes précédentes demande de faire des changements de coordonnées, chaque sommet voyant ses coordonnées remplacées par de nouvelles. Or, un changement de coordonnée s'effectue assez simplement, avec des matrices, à savoir des tableaux organisés en lignes et en colonnes avec un nombre dans chaque case. Un changement de coordonnées se fait simplement en multipliant le vecteur (X, Y, Z) des coordonnées d'un sommet par une matrice adéquate. Il existe des matrices pour la translation, la mise à l'échelle, d'autres pour la rotation, une autre pour la transformation de la caméra, une autre pour l'étape de projection, etc. Un changement de coordonnée s'effectue assez simplement en multipliant le vecteur-coordonnées (X, Y, Z) d'un sommet par une matrice adéquate. Un petit problème est que les matrices qui le permettent sont des matrices avec 4 lignes et 4 colonnes. Or, la multiplication demande que le nombre de coordonnées du vecteur soit égal au nombre de colonnes. Pour résoudre ce petit problème, on ajoute une 4éme coordonnée aux sommets, la coordonnée homogène, qui ne sert à rien, et est souvent mise à 1, par défaut. Mais oublions ce détail. Il se trouve que multiplier des matrices amène certaines simplifications. Au lieu de faire plusieurs multiplications de matrices, il est possible de fusionner les matrices en une seule, ce qui permet de simplifier les calculs. Ce qui fait que le placement des objets, changement de repère pour centrer la caméra, et d'autres traitements forts différents sont regroupés ensemble. Le traitement de la géométrie implique, sans surprise, des calculs de géométrie dans l'espace. Et cela implique des opérations mathématiques aux noms barbares : produits scalaires, produits vectoriels, et autres calculs impliquant des vecteurs et/ou des matrices. Et les calculs vectoriels/matriciels impliquent beaucoup d'additions, de soustractions, de multiplications, de division, mais aussi des opérations plus complexes : calculs trigonométriques, racines carrées, inverse d'une racine carrée, etc. Au final, un simple processeur peut faire ce genre de calculs, si on lui fournit le programme adéquat, l'implémentation est assez aisée. Mais on peut aussi implémenter le tout avec un circuit spécialisé, non-programmable. Les deux solutions sont possibles, tant que le circuit dispose d'assez de puissance de calcul. Les cartes graphiques anciennes contenaient un ou plusieurs circuits de multiplication de matrices spécialisés dans l'étape de transformation. Chacun de ces circuits prend un sommet et renvoie le sommet transformé. Ils sont composés d'un gros paquet de multiplieurs et d'additionneurs flottants. Pour plus d'efficacité, les cartes graphiques comportent plusieurs de ces circuits, afin de pouvoir traiter plusieurs sommets en même temps. ==L'élimination des surfaces cachées== Un point important du rendu 3D est que ce que certaines portions de la scène 3D ne sont pas visibles depuis la caméra. Et idéalement, les portions de la scène 3D qui ne sont pas visibles à l'écran ne doivent pas être calculées. A quoi bon calculer des choses qui ne seront pas affichées ? Ce serait gâcher de la puissance de calcul. Et pour cela, de nombreuses optimisations visent à éliminer les calculs inutiles. Elles sont regroupées sous les termes de '''''clipping''''' ou de '''''culling'''''. La différence entre ''culling'' et ''clipping'' n'est pas fixée et la terminologie n'est pas claire. Dans ce qui va suivre, nous n'utiliserons que le terme ''culling''. Les cartes graphiques modernes embarquent diverses méthodes de ''culling'' pour abandonner les calculs quand elles s’aperçoivent que ceux-ci portent sur une partie non-affichée de l'image. Cela fait des économies de puissance de calcul assez appréciables et un gain en performance assez important. Précisons que le ''culling'' peut être plus ou moins précoce suivant le type de rendu 3D utilisé, mais nous verrons cela dans la suite du chapitre. ===Les différentes formes de ''culling''/''clipping''=== La première forme de ''culling'' est le '''''view frustum culling''''', dont le nom indique qu'il s'agit de l'élimination de tout ce qui est situé en-dehors du ''view frustum''. Ce qui est en-dehors du champ de vision de la caméra n'est pas affiché à l'écran n'est pas calculé ou rendu, dans une certaine mesure. Le ''view frustum culling'' est assez trivial : il suffit d'éliminer ce qui n'est pas dans le ''view frustum'' avec quelques calculs de coordonnées assez simples. Quelques subtilités surviennent quand un triangle est partiellement dans le ''view frustrum'', ce qui arrive parfois si le triangle est sur un bord de l'écran. Mais rien d'insurmontable. [[File:View frustum culling.svg|centre|vignette|upright=1|''View frustum culling'' : les parties potentiellement visibles sont en vert, celles invisibles en rouge et celles partiellement visibles en bleu.]] Les autres formes de ''culling'' visent à éliminer ce qui est dans le ''view frustum'', mais qui n'est pas visible depuis la caméra. Pensez à des objets cachés par un autre objet plus proche, par exemple. Ou encore, pensez aux faces à l'arrière d'un objet opaque qui sont cachées par l'avant. Ces deux cas correspondent à deux types de ''culling''. L'élimination des objets masqués par d'autres est appelé l'''occlusion culling''. L'élimination des parties arrières d'un objet est appelé le ''back-face culling''. Dans les deux cas, nous parlerons d''''élimination des surfaces cachées'''. [[File:Occlusion culling example PL.svg|centre|vignette|''Occlusion culling'' : les objets en bleu sont visibles, ceux en rouge sont masqués par les objets en bleu.]] Le lancer de rayons n'a pas besoin d'éliminer les surfaces cachées, il ne calcule que les surfaces visibles. Par contre, la rastérisation demande d'éliminer les surfaces cachées. Sans cela, le rendu est incorrect dans le pire des cas, ou alors le rendu calcule des surfaces invisibles pour rien. Il existe de nombreux algorithmes logiciels pour implémenter l'élimination des surfaces cachées, mais la carte graphique peut aussi s'en charger. L'''occlusion culling'' demande de connaitre la distance à la caméra de chaque triangle. La distance à la caméra est appelée la '''profondeur''' du triangle. Elle est déterminée à l'étape de rastérisation et est calculée à chaque sommet. Lors de la rastérisation, chaque sommet se voit attribuer trois coordonnées : deux coordonnées x et y qui indiquent sa position à l'écran, et une coordonnée de profondeur notée z. ===L'algorithme du peintre=== Pour éliminer les surfaces cachées, la solution la plus simple consiste simplement à rendre les triangles du plus lointain au plus proche. L'idée est que si deux triangles se recouvrent totalement ou partiellement, on doit dessiner celui qui est derrière, puis celui qui est devant. Le dessin du second va recouvrir le premier. Quelque chose qui devrait vous rappeler le rendu 2D, où les sprites sont rendus du plus lointain au plus proche. Il ne s'agit ni plus ni moins que de l''''algorithme du peintre'''. [[File:Polygons cross.svg|vignette|Polygons cross]] Un problème est que la solution ne marche pas avec certaines configurations particulières, dans le cas où des polygones un peu complexes se chevauchent plusieurs fois. Il se présente rarement dans un rendu 3D normal, mais c'est quand même un cas qu'il faut gérer. Le problème est suffisant pour que cette solution ne soit plus utilisée dans le rendu 3D normal. Un autre problème est que l'algorithme demande de trier les triangles d'une scène 3D selon leur profondeur, du plus profond au moins profond. Et les cartes graphiques n'aiment pas ça, que ce soit les anciennes cartes graphiques comme les modernes. Il s'agit généralement d'une tâche qui est réalisée par le processeur, le CPU, qui est plus efficace que le GPU pour trier des trucs. Aussi, l'algorithme du peintre était utilisé sur d'anciennes cartes graphiques, qui ne géraient pas la géométrie mais seulement les textures et quelques effets de post-processing. Avec ces GPU, les jeux vidéo calculaient la géométrie et la triait sur le CPU, puis effectuaient le reste de la rastérisation sur le GPU. Les anciens jeux en 2.5D comme DOOM ou les DOOM-like, utilisaient une amélioration de l'algorithme du peintre. L'amélioration variait suivant le moteur de jeu utilisé, et donnait soit une technique dite de ''portal rendering'', soit un système de ''Binary Space Partionning'', assez complexes et difficiles à expliquer. Mais il ne s'agissait pas de jeux en 3D, les maps de ces jeux avaient des contraintes qui rendaient cette technique utilisable. Ils n'avaient pas de polygones qui se chevauchent, notamment. ===Le tampon de profondeur=== [[File:Z-buffer no text.jpg|vignette|Z-buffer correspondant à un rendu]] Une autre solution utilise ce qu'on appelle un '''tampon de profondeur''', aussi appelé un ''z-buffer''. Il s'agit d'un tableau, stocké en mémoire vidéo, qui mémorise la coordonnée z de l'objet le plus proche pour chaque pixel. Par défaut, ce tampon de profondeur est initialisé avec la valeur de profondeur maximale, celle du ''far plane'' du ''viewfrustum''. Au fur et à mesure que les objets seront calculés, le tampon de profondeur est mis à jour, conservant ainsi la trace de l'objet le plus proche de la caméra. Si jamais un triangle a une coordonnée z plus grande que celle du tampon de profondeur, cela veut dire qu'il est situé derrière un objet déjà rendu. Il est éliminé (sauf si transparence il y a) et le tampon de profondeur n'a pas à être mis à jour. Dans le cas contraire, l'objet est plus près de la caméra et sa coordonnée z remplace l'ancienne valeur z dans le tampon de profondeur. [[File:Z-buffer.svg|centre|vignette|upright=2.0|Illustration du processus de mise à jour du Z-buffer.]] Il existe des techniques alternatives pour coder la coordonnée de profondeur, qui se distinguent par le fait que la coordonnée z n'est pas proportionnelle à la distance entre le fragment et la caméra. Mais il s'agit là de détails assez mathématiques que je me permets de passer sous silence. Dans la suite de ce cours, nous allons juste parler de profondeur pour regrouper toutes ces techniques, conventionnelles ou alternatives. Toutes les cartes graphiques modernes utilisent un système de ''z-buffer''. C'est la seule solution pour avoir des performances dignes de ce nom. Il faut cependant noter qu'elles utilisent des tampons de profondeur légèrement modifiés, qui ne mémorisent pas la coordonnée de profondeur, mais une valeur dérivée. Pour simplifier, ils ne mémorisent pas la coordonnée de profondeur z, mais son inverse 1/z. Les raisons à cela ne peuvent pas encore être expliquées à ce moment du cours, aussi nous allons simplement dire que c'est une histoire de correction de perspective. Les coordonnées z et 1/z sont codées sur quelques bits, allant de 16 bits pour les anciennes cartes graphiques, à 24/32 bits pour les cartes plus récentes. De nos jours, les Z-buffer de 16 bits sont abandonnés et toutes les cartes graphiques utilisent des coordonnées z de 24 à 32 bits. La raison est que les Z-buffer de 16 bits ont une précision insuffisante, ce qui fait que des artefacts peuvent survenir. Si deux objets sont suffisamment proches, le tampon de profondeur n'a pas la précision suffisante pour discriminer les deux objets. Pour lui, les deux objets sont à la même place. Conséquence : il faut bien choisir un des deux objets et ce choix se fait pixel par pixel, ce qui fait des artefacts visuels apparaissent. On parle alors de '''''z-fighting'''''. Voici ce que cela donne : [[File:Z-fighting.png|centre|vignette|Z-fighting]] Un défaut du tampon de profondeur est qu'il ne gère pas correctement les objets transparents. Dès que de la transparence est présente dans une scène 3D, le tampon de profondeur ne peut pas être utilisé. Une solution pour cela est de rendre une scène 3D en deux phases : une pour les objets opaques, une avec les objets transparents. La où on rend les objets opaques utilise le tampon de profondeur, mais il est désactivé lors de la seconde. ==La rastérisation et les textures== Dans cette section, nous allons voir ensemble l'étape de rastérisation et l'étape de traitement des pixels. La rastérisation est difficile à expliquer, surtout que son rôle exact dépend de la technique de rendu utilisée. Pour simplifier, elle projette un rendu en 3D sur un écran en 2D. Une autre explication tout aussi vague est qu'elle s'occupe la traduction des triangles en un affichage pixelisé à l'écran. Elle détermine à quoi ressemble la scène visible sur l'écran. C'est par exemple lors de cette étape que sont appliquées certaines techniques de ''culling'', qui éliminent les portions non-visibles de l'image, ainsi qu'une correction de la perspective et diverses opérations d'interpolation dont nous parlerons dans plusieurs chapitres. La rastérisation et placage de textures sont deux opérations très liées entre elles. Il existe deux manières principales pour lier les textures à la géométrie : la méthode directe et la méthode inverse (''UV Mapping''). Et les deux font que la rastérisation se fait de manière très différente. Précisons cependant que les rendus les plus simples n'utilisent pas de textures du tout. Ils se contentent de colorier les triangles, voire d'un simple rendu en fil de fer basé sur du tracé de lignes. Dans la suite de cette section, nous allons voir les quatre types de rendu principaux : le rendu en fils de fer, le rendu colorié, et deux rendus utilisant des textures. ===Le rendu en fil de fer=== [[File:Obj lineremoval.png|vignette|Rendu en fil de fer d'un objet 3D.]] Le '''rendu 3D en fils de fer''' est illustré ci-contre. Il s'agit d'un rendu assez ancien, utilisé au tout début de la 3D, sur des machines qu'on aurait du mal à appeler ordinateurs. Il se contente de tracer des lignes à l'écran, lignes qui connectent deux sommets, qui ne sont autres que les arêtes de la géométrie de la scène rendue. Le tout était suffisant pour réaliser quelques jeux vidéos rudimentaires. Les tout premiers jeux vidéos utilisaient ce rendu, l'un d'entre eux étant Maze War, le tout premier FPS. {| |[[File:Maze war.jpg|vignette|Maze war]] |[[File:Maze representation using wireframes 2022-01-10.gif|centre|vignette|Maze representation using wireframes 2022-01-10]] |} Le monde est calculé en 3D, il y a toujours un calcul de la géométrie, la scène est rastérisée normalement, les portions invisbles de l'image sont retirées, mais il n'y a pas d'application de textures après rastérisation. A la place, un algorithme de tracé de ligne trace les lignes à l'écran. Quand un triangle passe l'étape de rastérisation, l'étape de rastérisation fournit la position des trois sommets sur l'écran. En clair, elle fournit les coordonnées de trois pixels, un par sommet. A la suite, un algorithme de tracé de ligne trace trois lignes, une par paire de sommet. L'implémentation demande juste d'avoir une unité de calcul géométrique, une unité de rastérisation, et un VDC qui supporte le tracé de lignes. Elle est donc assez simple et ne demande pas de circuits de gestion des textures ni de ROP. Le VDC écrit directement dans le ''framebuffer'' les lignes à tracer. Il a existé des proto-cartes graphiques spécialisées dans ce genre de rendu, comme le '''''Line Drawing System-1''''' de l'entreprise Eans & Sutherland. Nous détaillerons son fonctionnement dans quelques chapitres. ===Le rendu à primitives colorées=== [[File:MiniFighter.png|vignette|upright=1|Exemple de rendu pouvant être obtenu avec des sommets colorés.]] Une amélioration du rendu précédent utilise des triangles/''quads'' coloriés. Chaque triangle ou ''quad'' est associé à une couleur, et cette couleur est dessinée sur le triangle/''quad''après la rastérisation. Le rendu est une amélioration du rendu en fils de fer. L'idée est que chaque triangle/''quad'' est associé à une couleur, qui est dessinée sur le triangle/''quad'' après la rastérisation. La technique est nommée ''colored vertices'' en anglais, nous parlerons de '''rendu à maillage coloré'''. [[File:Malla irregular de triángulos modelizando una superficie convexa.png|centre|vignette|upright=2|Maillage coloré.]] La couleur est propagée lors des calculs géométriques et de la rastérisation, sans subir de modifications. Une fois un rendu en fils de fer effectué, la couleur du triangle est récupérée. Le triangle/''quad'' rendu correspond à un triangle/''quad'' à l'écran. Et l'intérieur de ce triangle/''quad'' est colorié avec la couleur transmise. Pour cela, on utilise encore une fois une fonction du VDC : celle du remplissage de figure géométrique. Nous l’avions vu en parlant des VDC à accélération 2D, mais elle est souvent prise en charge par les ''blitters''. Ils peuvent remplir une figure géométrique avec une couleur unique, on réutilise cette fonction pour colorier le triangle/''quad''. L'étape de rastérisation fournit les coordonnées des sommets de la figure géométrique, le ''blitter'' les utilise pour colorier la figure géométrique. Niveau matériel, quelques bornes d'arcade ont utilisé ce rendu. La toute première borne d'arcade utilisant le rendu à maillage coloré est celle du jeu I Robot, d'Atari, sorti en 1983. Par la suite, dès 1988, les cartes d'arcades Namco System 21 et les bornes d'arcades Sega Model 1 utilisaient ce genre de rendu. On peut s'en rendre compte en regardant les graphismes des jeux tournant sur ces bornes d'arcade. Des jeux comme Virtua Racing, Virtua Fighter ou Virtua Formula sont assez parlants à ce niveau. Leurs graphismes sont assez anguleux et on voit qu'ils sont basés sur des triangles uniformément colorés. Pour ceux qui veulent en savoir plus sur la toute première borne d'arcade en rendu à maillage colorée, la borne ''I Robot'' d'Atari, voici une vidéo youtube à ce sujet : * [https://www.youtube.com/watch?v=6miEkPENsT0 I Robot d'Atari, le pionnier de la 3D Flat.] ===Le placage de textures direct=== Les deux rendus précédents sont très simples, mais n'utilisent pas de textures. Et il est temps de voir les deux rendus qui utilisent des textures. Il y en a deux types, appelés rendu avec placage de texture direct et indirect, nous allons voir le '''rendu par placage de texture direct''' en premier. Et nous l'appellerons ''rendu direct'' dans ce qui suit, pour simplifier les explications. L'idée est assez simple et peut utiliser aussi bien des triangles que des ''quads'', mais nous allons partir du principe qu'elle utilise des '''''quads''''', à savoir que les objets 3D sont composés de quadrilatères. Lorsqu'un ''quad'' est rastérisé, sa forme à l'écran est un rectangle déformé par la perspective. On obtient un rectangle si le ''quad'' est vu de face, un trapèze si on le voit de biais. Et le ''sprite'' doit être déformé de la même manière que le ''quad''. L'idée est que tout quad est associé à une texture, à un sprite. La figure géométrique qui correspond à un ''quad'' à l'écran est remplie non pas par une couleur uniforme, mais par un ''sprite'' rectangulaire. Il suffit techniquement de recopier le ''sprite'' à l'écran, c'est à dire dans la figure géométrique, au bon endroit dans le ''framebuffer''. Le rendu direct est en effet un intermédiaire entre rendu 2D à base de ''sprite'' et rendu 3D moderne. La géométrie est rendue en 3D pour générer des ''quads'', mais ces ''quads'' ne servent à guider la copie des sprites/textures dans le ''framebuffer''. [[File:TextureMapping.png|centre|vignette|upright=2|Exemple caricatural de placage de texture sur un ''quad''.]] La subtilité est que le sprite est déformé de manière à rentrer dans un quadrilatère, qui n'est pas forcément un rectangle à l'écran, mais est déformé par la perspective et son orientation en 3D. Le sprite doit être déformé de deux manières : il doit être agrandi/réduit en fonction de la taille de la figure affichée à l'écran, tourné en fonction de l'orientation du ''quad'', déformé pour gérer la perspective. Pour cela, il faut connaitre les coordonnées de profondeur de chaque bord d'un ''quad'', et de faire quelques calculs. N'importe quel VDC incluant un ''blitter'' avec une gestion du zoom/rotation des sprites peut le faire. : Si on veut avoir de beaux graphismes, il vaut mieux appliquer un filtre pour lisser le sprite envoyé dans le trapèze, filtre qui se résume à une opération d'interpolation et n'est pas très différent du filtrage de texture qui lisse les textures à l'écran. Un autre point est que les ''quads'' doivent être rendus du plus lointain au plus proche. Sans cela, on obtient rapidement des erreurs de rendu. L'idée est que si deux quads se chevauchent, on doit dessiner celui qui est derrière, puis celui qui est devant. Le dessin du second va recouvrir le premier. L'écriture du sprite du second quad écrasera les données du premier quad, pour les portions recouvertes, lors de l'écriture du sprite dans le ''framebuffer''. Quelque chose qui devrait vous rappeler le rendu 2D, où les sprites sont rendus du plus lointain au plus proche. Le rendu inverse utilise très souvent des triangles pour la géométrie, alors que le rendu direct a tendance à utiliser des ''quads'', mais il ne s'agit pas d'une différence stricte. L'usage de triangles/''quads'' peut se faire aussi bien avec un rendu direct comme avec un rendu inverse. Cependant, le rendu en ''quad'' se marie très bien au rendu direct, alors que le rendu en triangle colle mieux au rendu inverse. L'avantage de cette technique est qu'on parcourt les textures dans un ordre bien précis. Par exemple, on peut parcourir la texture ligne par ligne, l'exploiter par blocs de 4*4 pixels, etc. Et accéder à une texture de manière prédictible se marie bien avec l'usage de mémoires caches, ce qui est un avantage en matière de performances. Mais un même pixel du ''framebuffer'' est écrit plusieurs fois quand plusieurs quads se superposent, alors que le rendu inverse gère la situation avec une seule écriture (sauf si usage de la transparence). De plus, la gestion de la transparence était compliquée et les jeux devaient ruser en utilisation des solutions logicielles assez complexes. Niveau implémentation matérielle, une carte graphique en rendu direct demande juste trois circuits. Le premier est un circuit de calcul géométrique, qui rend la scène 3D. Le tri des quads est souvent réalisé par le processeur principal, et non pas par un circuit séparé. Toutes les étapes au-delà de l'étape de rastérisation étaient prises en charge par un VDC amélioré, qui écrivait des sprites/textures directement dans le ''framebuffer''. {|class="wikitable" |- ! Géométrie | Processeurs dédiés programmé pour émuler le pipeline graphique |- ! Tri des quads du plus lointain au plus proche | Processeur principal (implémentation logicielle) |- ! Application des textures | ''Blitter'' amélioré, capable de faire tourner et de zoomer sur des ''sprites''. |} L'implémentation était très simple et réutilisait des composants déjà existants : des VDC 2D pour l'application des textures, des processeurs dédiés pour la géométrie. Les unités de calcul de la géométrie étaient généralement implémentées avec un ou plusieurs processeurs dédiés. Vu qu'on savait déjà effectuer le rendu géométrique en logiciel, pas besoin de créer un circuit sur mesure. Il suffisait de dédier un processeur spécialisé rien que pour les calculs géométriques et on lui faisait exécuter un code déjà bien connu à la base. En clair, ils utilisaient un code spécifique pour émuler un circuit fixe. C'était clairement la solution la plus adaptée pour l'époque. Les unités géométriques étaient des processeurs RISC, normalement utilisés dans l'embarqué ou sur des serveurs. Elles utilisaient parfois des DSP. Pour rappel, les DSP des processeurs de traitement de signal assez communs, pas spécialement dédiés aux rendu 3D, mais spécialisé dans le traitement de signal audio, vidéo et autre. Ils avaient un jeu d'instruction assez proche de celui des cartes graphiques actuelles, et supportaient de nombreuses instructions utiles pour le rendu 3D. Le rendu direct a été utilisé dans la période de transition entre rendu 2D et rendu 3D, car il était très adapté pour faire cette transition. Coupler un VDC à un processeur pour la géométrie était particulièrement simple à l'époque. Le rendu direct a été utilisé sur des bornes d'arcade dès les années 90. Outre les bornes d'arcade, quelques consoles de 5ème génération utilisaient le rendu direct, avec les mêmes solutions matérielles. La géométrie était calculée sur plusieurs processeurs dédiés. Le reste du pipeline était géré par un VDC 2D qui implémentait le placage de textures. Deux consoles étaient dans ce cas : la 3DO, et la Sega Saturn. Le rendu direct est aujourd'hui abandonné. ===Le placage de textures inverse=== Le rendu précédent, le rendu direct, permet d'appliquer des textures directement dans le ''framebuffer''. Mais comme dit plus haut, il existe une seconde technique pour plaquer des textures, appelé le '''placage de texture inverse''', aussi appelé l'''UV Mapping''. Elle associe une texture complète pour un modèle 3D,contrairement au placage de tecture direct qui associe une texture par ''quad''/triangle. L'idée est que l'on attribue un texel à chaque sommet. Plus précisémment, chaque sommet est associé à des '''coordonnées de texture''', qui précisent quelle texture appliquer, mais aussi où se situe le texel à appliquer dans la texture. Par exemple, la coordonnée de texture peut dire : je veux le pixel qui est à ligne 5, colonne 27 dans cette texture. La correspondance entre texture et géométrie est réalisée lorsque les créateurs de jeu vidéo conçoivent le modèle de l'objet. [[File:Texture Mapping example.png|centre|vignette|upright=2|Exemple de placage de texture.]] Dans les faits, on n'utilise pas de coordonnées entières de ce type, mais deux nombres flottants compris entre 0 et 1. La coordonnée 0,0 correspond au texel en bas à gauche, celui de coordonnée 1,1 est tout en haut à droite. L'avantage est que ces coordonnées sont indépendantes de la résolution de la texture, ce qui aura des avantages pour certaines techniques de rendu, comme le ''mip-mapping''. Les deux coordonnées de texture sont notées u,v avec DirectX, ou encore s,t dans le cas général : u est la coordonnée horizontale, v la verticale. [[File:UVMapping.png|centre|vignette|upright=2|UV Mapping]] Avec le placage de texture inverse, la rastérisation se fait grosso-modo en trois étapes : la rastérisation proprement dite, le placage de textures, et les opérations finales qui écrivent un pixel dans le ''framebuffer''. Au niveau du matériel, ainsi que dans la plupart des API 3D, les trois étapes sont réalisées par des circuits séparés. [[File:01 3D-Rasterung-a.svg|vignette|Illustration du principe de la rasterization. La surface correspondant à l'écran est subdivisée en pixels carrés, de coordonnées x et y. La caméra est placée au point e. Pour chaque pixel, on trace une droite qui part de la caméra et qui passe par le pixel considéré. L'intersection entre une surface et cette droite se fait en un point, appartenant à un triangle.]] Lors de la rasterisation, chaque triangle se voit attribuer un ou plusieurs pixels à l'écran. Pour bien comprendre, imaginez une ligne droite qui part de caméra et qui passe par un pixel sur le plan de l'écran. Cette ligne intersecte 0, 1 ou plusieurs objets dans la scène 3D. Les triangles situés ces intersections entre cette ligne et les objets rencontrés seront associés au pixel correspondant. L'étape de rastérisation prend en entrée un triangle et renvoie la coordonnée x,y du pixel associé. Il s'agit là d'une simplification, car un triangle tend à occuper plusieurs pixels sur l'écran. L'étape de rastérisation fournit la liste de tous les pixels occupés par un triangle, et les traite un par un. Quand un triangle est rastérisé, le rasteriseur détermine la coordonnée x,y du premier pixel, applique une texture dessus, puis passe au suivant, et rebelote jusqu'à ce que tous les pixels occupés par le triangles aient été traités. L'implémentation matérielle du placage de texture inverse est beaucoup plus complexe que pour les autres techniques. Pour être franc, nous allons passer le reste du cours à parler de l'implémentation matérielle du placage de texture inverse, ce qui prendra plus d'une dizaine de chapitres. ==La transparence, les fragments et les ROPs== Dans ce qui suit, nous allons parler uniquement de la rastérisation avec placage de textures inverse. Les autres formes de rastérisation ne seront pas abordées. La raison est que tous les GPUs modernes utilisent cette forme de rastérisation, les exceptions étant rares. De même, ils utilisent un tampon de profondeur, pour l'élimination des surfaces cachées. La rastérisation effectue donc des calculs géométriques, suivis d'une étape de rastérisation, puis de placage des textures. Ces trois étapes sont réalisées par une unité géométrique, une unité de rastérisation, et un circuit de placage de textures. Du moins sur le principe, car les cartes graphiques modernes ont fortement optimisé l'implémentation et n'ont pas hésité à fusionner certains circuits. Mais nous verrons cela en temps voulu, nous n'allons pas résumer plusieurs décennies d'innovation technologique en quelques paragraphes. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Placage de textures |} Mais où mettre le tampon de profondeur ? Intuitivement, on se dit qu'il vaut mieux faire l'élimination des surfaces cachées le plus tôt possible, dès que la coordonnée de profondeur est connue. Et elle est connu à l'étape de rastérisation, une fois les sommets transformés. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Tampon de profondeur | Placage de textures |} En réalité, la profondeur des fragments est gérée par un circuit appelé le '''''Raster Operations Pipeline''''' (ROP), situé à la toute fin du pipeline graphique. Dans ce qui suit, nous utiliserons l'abréviation ROP pour simplifier les explications. Le ROP effectue quelques traitements sur les fragments, avant d'enregistrer l'image finale dans la mémoire vidéo. Il est placé à la fin du pipeline pour gérer correctement la transparence. Et nous allons voir pourquoi la transparence est gérée à la fin du pipeline. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Placage de textures | ''Raster Operations Pipeline'' |} ===Le mélange ''alpha''=== La transparence se manifeste quand plusieurs objets sont l'un derrière l'autre. Histoire de simplifier les explications, nous allons d'abord voir le cas où un objet semi-transparent est devant un objet opaque. La couleur perçue est alors un mélange de la couleur de l'objet opaque et celle de l'objet semi-transparent. Le mélange dépend d'à quel point l'objet semi-transparent est transparent. Avec un objet parfaitement transparent, seul l'objet opaque est visible. Avec un objet à moitié transparent, la couleur finale sera pour moitié celle de l'objet opaque, pour moitié celle de l'objet semi-transparent. Et c'est pareil pour les cas intermédiaires entre un objet totalement transparent et un objet totalement opaque. La transparence d'un objet/pixel est définie par un nombre, appelé la '''composante ''alpha'''''. Plus la composante alpha est élevée, plus le pixel est opaque. Elle vaut 0 pour un objet opaque et 1 pour un objet transparent. Elle est ajoutée aux composantes RGB, ce qui fait que tout fragment contient une "couleur de transparence" en plus des couleurs RGB. Elle agit comme un coefficient qui dit comment mélanger la couleur d'un objet transparent et d'un objet opaque. Le calcul de la transparence est une moyenne pondérée par la composante alpha. On parle alors d''''''alpha blending'''''. : <math>\text{Couleur finale} = \alpha \times \text{Couleur de l'objet transparent} + (1 - \alpha) \times \text{Couleur de l'objet opaque}</math> [[File:Texture splatting.png|centre|vignette|upright=2.0|Calcul de transparence. La première ligne montre le produit pour l'objet transparent, la seconde ligne est celle de l'objet opaque. La troisième ligne est celle de l'addition finale.]] Maintenant, qu'en est-il du cas où plusieurs objets sont superposés ? Si vous tracez une demi-droite dont l'origine est la caméra et qui passe par le pixel, il arrive qu'elle intersecte la géométrie en plusieurs points, un point par objet sur la ligne du regarde. Sans transparence, l'objet le plus proche cache tous les autres et c'est donc lui qui décide de la couleur du pixel. Mais avec un objet transparent, la couleur finale est un mélange de la couleur de plusieurs points d'intersection. Il faut donc calculer un pseudo-pixel pour chaque point d'intersection, auquel on donne le nom de '''fragment'''. Un fragment possède une position à l'écran, une coordonnée de profondeur, une couleur, ainsi que quelques autres informations potentiellement utiles. Les fragments attribués à un même pixel, qui sont à la même position sur l'écran, sont combinés pour obtenir la couleur finale de ce pixel. Il est possible d'utiliser le mélange ''alpha'' pour cela. Il suffit de faire le mélange ''alpha'' entre le fragment qui vient d'être calculé, et le pixel dans le ''framebuffer''. Pour cela, le fragment a une composante ''alpha'', qui est ajouté aux trois couleurs RGB. Le pixel déjà dans le ''framebuffer'' est un résultat temporaire, né du mélange ''alpha'' de tous les fragments précédents. Un défaut de cette méthode est qu'elle fonctionne assez mal avec un tampon de profondeur. Si le tampon de profondeur est activé, le mélange ''alpha'' ne fonctionne que si les objets sont rendus du plus lointain au plus proche. Et procéder dans cet ordre a un défaut : on dessine des objets dans le ''framebuffer'', pour qu'ensuite les objets devant écrasent ce qui a déjà été dessiné. Un même pixel peut donc être dessiné plusieurs fois, dont une seule sera pertinente. Et ces écritures utilisent de la bande passante mémoire, qui est une ressource précieuse sur un GPU moderne. Il s'agit d'un phénomène appelé '''''overdraw''''', ou sur-dessinage en français. Quelques optimisations permettent d'éliminer l'''overdraw'' en rendant les objets du plus proche au plus lointain, d'autres permettent de dessiner des objets dans un ordre arbitraire, mais nous ne pouvons pas en parler ici. Beaucoup de moteurs 3D rendent séparément les objets opaques et transparents. Une première passe rend les objets opaques, puis les objets transparents sont rendus dans une seconde passe. Les objets opaques sont rendus dans le désordre, ce qui fait qu'on n'a pas à les trier, alors que les objets transparents doivent être triés selon leur distance. un autre avantage est que le mélange ''alpha'' est désactivé lors de la première passe, alors que c'est la mise à jour du tampon de profondeur qui est désactivé lors de la seconde passe, ce qui augmente un peu les performances dans les deux cas. ===Le test ''alpha''=== Le test ''alpha'' est une technique qui permet d'annuler le rendu d'un fragment en fonction de sa transparence. Si la composante alpha est en-dessous ou au-dessus d'un seuil, le fragment est simplement abandonné. Le seuil en question est configurable, de même que la comparaison utilisée : on peut éliminer le fragment si sa transparence est au-dessus d'un certain seuil, en-dessous, égal, différent, etc. Il s'agit d'une optimisation qui est utile dans certains scénarios spécifiques. Par exemple, si l'objet a une transparence très élevée, du genre 95%, autant le compter comme complétement transparent, afin d'éviter des opérations de mélange ''alpha''. En effet, les opérations de mélange ''alpha'' sont très lentes, car elles demandent de faire des opérations de lecture-écriture en mémoire vidéo : on lit un pixel dans le ''framebuffer'', on applique le mélange ''alpha'' et on écrit le résultat en mémoire vidéo. L'''alpha test'' permet donc de gagner en performance au prix d'une baisse de la qualité d'image. Il y a cependant des cas où l'usage du test ''alpha'' est primordial, au-delà d'une question de performances. Un exemple classique est celui du rendu du feuillage dans un jeu 3D. Un feuillage est composé en assemblant plusieurs images de feuilles. Chaque feuille est un carré sur lequel on place une texture de feuille, qui est opaque pour la partie verte des feuilles, transparente pour le reste. Les carrés ne sont cependant pas superposés, mais s'intersectent fortement, ce qui fait que le mélange ''alpha'' ne donne pas de bons résultats. L'usage du test ''alpha'' permet d'obtenir un rendu correct. Pour d'informations via ce lien : * [https://bgolus.medium.com/anti-aliased-alpha-test-the-esoteric-alpha-to-coverage-8b177335ae4f Anti-aliased Alpha Test: The Esoteric Alpha To Coverage]. ===Les effets de brouillard=== Les '''effets de brouillard''' sont nécessaires dans certains jeux vidéo pour l'ambiance (pensez à des jeux d'horreur comme Silent Hill), mais ils ont surtout été utilisés pour économiser des calculs. L'idée est de ne pas calculer les graphismes au-delà d'une certaine distance, sans que cela se voie. Le ''view frustum'' utilise alors un plan limite, au-delà duquel on ne voit pas les objets. Mais ce plan limite donne une cassure inesthétique dans le rendu. Pour masquer cette cassure, les programmeurs ajoutaient un effet de brouillard. Les objets au-delà du plan limite étaient totalement dans le brouillard, puis ce brouillard se réduisait progressivement en se rapprochant de la caméra, avant de s'annuler à partir d'une certaine distance. Pour calculer le brouillard, on effectue un mélange ''alpha'' entre la couleur du pixel et une ''couleur de brouillard''. La différence est que l'on n'utilise pas la transparence pour faire le mélange, mais un '''coefficient de brouillard''', noté <math>\text{fog}(z)</math>. : <math>\text{Couleur finale} = \text{fog}(z) \times \text{Couleur de brouillard} + [ 1 - \text{fog}(z) ] \times \text{Couleur du pixel}</math> Le coefficient de brouillard dépend de la coordonnée de profondeur, de la distance du pixel par rapport à la caméra. Le brouillard démarre à une distance <math>z_{fog-start}</math>, et masque totalement les objets à partir d'une distance <math>z_{fog-end}</math>. Entre les deux, le coefficient de brouillard dépend de la distance. OpenGL autorise trois formules de calcul suivantes : : <math>\text{fog}(z) = \frac{z_{fog-end} - z}{z_{fog-end} - z_{fog-start}}</math> : <math>\text{fog}(z) = e^{- k \times z}</math> : <math>\text{fog}(z) = e^{- (k \times z)^2}</math> ==L'éclairage d'une scène 3D== L'éclairage d'une scène 3D calcule les ombres, mais aussi la luminosité de chaque pixel, ainsi que bien d'autres effets graphiques. Les algorithmes d'éclairage ont longtemps été implémentés directement en matériel, les cartes graphiques géraient l'éclairage dans des circuits spécialisés. Aussi, il est important de voir ces algorithmes d'éclairage. Il est possible d'implémenter l'éclairage à deux endroits différents du pipeline : juste avant la rastérisation, et après la rastérisation. ===Les sources de lumière et les couleurs associées=== L'éclairage d'une scène 3D provient de sources de lumières, comme des lampes, des torches, le soleil, etc. Il existe de nombreux types de sources de lumière, et nous n'allons parler que des principales. Elles sont au nombre de quatre et elles sont illustrées ci-dessous. [[File:3udUJ.gif|centre|vignette|upright=2|Types de sources de lumière.]] [[File:Graphics lightmodel directional.png|vignette|upright=1.0|Source de lumière directionnelle.]] Les '''sources directionnelles''' servent à modéliser des sources de lumière très éloignées, comme le soleil ou la lune. Elles sont simplement définies par un vecteur qui indique la direction de la lumière, rien de plus. Les '''sources ponctuelles''' sont des points, qui émettent de la lumière dans toutes les directions. Elles sont définies par une position, et une intensité lumineuse, éventuellement la couleur de la lumière émise. Il existe deyux types de sources de lumière ponctuelles. * Le premières émettent de manière égale dans toutes les directions. Elles sont appelées des ''point light'' dans le schéma du dessus. * Les secondes émettent de la lumière dans une '''direction privilégiée'''. L'exemple le plus parlant est celui d'une lampe-torche : elle émet de la lumière "tout droit", dans la direction où la lampe est orientée. Elles sont appelées des ''sport light'' dans le schéma du dessus. La direction privilégiée est un vecteur, notée v dans le schéma du dessous. [[File:Graphics lightmodel ambient.png|vignette|upright=1.0|Lumière ambiante.]] En théorie, la lumière rebondit sur les surfaces et a tendance à se disperser un peu partout à force de rebondir. C'est ce qui explique qu'on arrive à voir à l'intérieur d'une pièce si une fenêtre est ouverte. Il en résulte un certain '''éclairage ambiant''', qui est assez difficile à représenter dans un moteur de rendu 3D. Auparavant, l'éclairage ambiant était simulé par une lumière égale en tout point de la scène 3D, appelée simplement la '''lumière ambiante'''. Précisément, on suppose que la lumière ambiante en un point vient de toutes les directions et a une intensité constante, identique dans toutes les directions. Le tout est illustré ci-contre. C'est assez irréaliste, mais ça donne une bonne approximation de la lumière ambiante. ===La lumière incidente : le terme géométrique=== Pour simplifier, nous allons supposer que l'éclairage est calculé pour chaque sommet, pas par triangle. C'est de loin le cas le plus courant, aussi ce n'est pas une simplification abusive. La lumière qui arrive sur un sommet est appelée la '''lumière incidente'''. La couleur d'un sommet dépend de deux choses : la lumière incidente directe, comment il réfléchit cette lumière. Mathématiquement, il est possible de résumer cela avec le produit de deux termes : l'intensité de la lumière incidente, une fonction qui indique comment la surface réfléchit la lumière incidente. La fonction en question est appelée la '''réflectivité bidirectionnelle'''. Le terme anglais est ''bidirectional reflectance distribution function'', abrévié en BRDF, et nous utiliserons cette abréviation dans ce qui suit. : <math>\text{Couleur finale} = \text{Lumière incidente} \times BRDF(...)</math> La lumière incidente vient soit directement des sources de lumière, soit de la lumière qui a rebondit sur d'autres objets proches. La première est appelée la lumière directe, celle qui vient des rebonds s'appelle la lumière indirecte. Pour simplifier, la lumière indirecte est gérée par la lumière ambiante, nous passons sous silence les techniques d'illumination globale. En clair : nous allons nous limiter au cas où la lumière incidente vient directement d'une source de lumière, pas d'un rebond. Intuitivement, la lumière incidente est simplement égale à l'intensité de la source de lumière. Sauf que ce n'est qu'une approximation, et une assez mauvaise. En réalité, l'approximation est bonne si la lumière arrive proche de la verticale, mais elle est d'autant plus mauvaise que la lumière arrive penchée, voire rasante. La raison : la lumière incidente sera étalée sur une surface plus grande, si elle arrive penchée. Si vous vous souvenez de vos cours de collège, c'est le même principe qui explique les saisons. La lumière du soleil est proche de la verticale en été, mais est de plus en plus penché quand on s'avance vers l'Hiver. La lumière solaire est donc étalée sur une surface plus grande, ce qui fait qu'un point de la surface recevra moins de lumière, celle-ci étant diluée, étalée. [[File:Radiación solar.png|centre|vignette|upright=2|Exemple avec la lumière solaire.]] [[File:Angle of incidence.svg|vignette|upright=1|Angle d'incidence.]] En clair, tout dépend de l''''angle d'incidence''' de la lumière. Reste à voir comment calculer cet angle. La lumière incidente est définie par un vecteur, qui part de la source de lumière et atterrit sur le sommet considéré. Imaginez simplement que ce vecteur suit un rayon lumineux provenant de la source de lumière. Le vecteur pour la lumière incidente sera noté L. L'angle d'incidence est l'angle que fait ce vecteur avec la verticale de la surface, au niveau du sommet considéré. [[File:Graphics lightmodel ptsource.png|vignette|Normale de la surface.]] Pour cela, les calculs d'éclairage ont besoin de connaitre la verticale d'un sommet. Un sommet est donc associé à un vecteur, appelé la '''normale''', qui indique la verticale en ce point. Deux sommets différents peuvent avoir deux normales différentes, même s'ils sont proches. Elles sont d'autant plus différentes que la surface est rugueuse, non-lisse. La normale est prédéterminée lors de la création du modèle 3D, il n'y a pas besoin de le calculer. Par contre, elle est modifiée lors de l'étape de transformation, quand on place le modèle 3D dans la scène 3D. Les deux autres vecteurs sont à calculer à chaque image, car ils changent quand on bouge le sommet. La lumière qui arrive sur la surface dépend de l'angle entre la normale et le vecteur L. Précisément, elle dépend du cosinus de cet angle. En multipliant ce cosinus avec l'intensité de la lumière, on a la lumière arrivante. La couleur finale d'un pixel est donc : : <math>\text{Couleur finale} = I \times \cos{(N, L)} \times BRDF(...)</math> Le terme <math>I \times \cos{N, L}</math> ne dépend pas de la surface considérée. Juste de la position de la source de lumière, de la position du sommet et de son orientation par rapport à la lumière. Aussi, il est parfois appelé le '''terme géométrique''', en opposition aux propriétés de la surface. Les propriétés de la surface sont définies par un '''''material''''', qui indique comment il réfléchit la lumière, ainsi que sa texture. ===Le produit scalaire de deux vecteurs=== Calculer le terme géométrique demande de calculer le cosinus d'un angle. Et il n'est pas le seul : les autres calculs d'éclairage que nous allons voir demandent de calculer des cosinus. Or, les calculs trigonométriques sont très gourmands pour le GPU. Pour éviter le calcul d'un cosinus, les GPU utilisent une opération mathématique appelée le ''produit scalaire''. Le produit scalaire agit sur deux vecteurs, que l'on notera A et B. Un produit scalaire prend : la longueur des deux vecteurs, et l'angle entre les deux vecteurs noté <math>\omega</math>. Le produit scalaire est équivalent à la formule suivante : : <math>\text{Produit scalaire de deux vecteurs A et B} = \vec{A} \cdot \vec{B} = A \times B \times \cos{(\omega)}</math>, avec A et B la longueur des deux vecteurs A et B. L'avantage est que le produit scalaire se calcule simplement avec des additions, soustractions et multiplications, des opérations que les cartes graphiques savent faire très facilement. Le produit scalaire de deux vecteurs de coordonnées x,y,z est le suivant : : <math>\vec{A} \cdot \vec{B} = x_A \times x_B + y_A \times y_B + z_A \times z_B</math> En clair, on multiplie les coordonnées identiques, et on additionne les résultats. Rien de compliqué. Un avantage est que tous les vecteurs vus précédemment sont normalisés, à savoir qu'ils ont une longueur qui vaut 1. Ainsi, le calcul du produit scalaire devient équivalent au calcul du produit scalaire. ===La réflexion de la lumière sur la surface=== [[File:Ray Diagram 2.svg|vignette|Reflection de la lumière sur une surface parfaitement lisse.]] Maintenant que nous venons de voir le terme géométrique, voyons le BRDF, qui définit comment la surface de l'objet 3D réfléchit la lumière. Vos cours de collège vous ont sans doute appris que la lumière est réfléchie avec le même angle d'arrivée. L'angle d'incidence et l'angle de réflexion sont égaux, comme illustré ci-contre. On parle alors de '''réflexion parfaite'''. Mais cela ne vaut que pour une surface parfaitement lisse, comme un miroir parfait. Dans la réalité, une surface a tendance à renvoyer des rayons dans toutes les directions. La raison est qu'une surface réelle est rugueuse, avec de petites aspérités et des micro-reliefs, qui renvoient la lumière dans des directions "aléatoires". La lumière « rebondit » sur la surface de l'objet et une partie s'éparpille dans un peu toutes les directions. On parle alors de '''réflexion diffuse'''. {| |- |[[File:Dioptre reflexion diffuse speculaire refraction.svg|vignette|upright=1.4|Différence entre réflexion diffuse et spéculaire.]] |[[File:Diffuse reflection.svg|vignette|upright=1|Réflexion diffuse.]] |} Maintenant, imaginons que la surface n'ait qu'une réflexion diffuse, pas d'autres formes de réflexion. Et imaginons aussi que cette réflexion diffuse soit parfaite, à savoir que la lumière réfléchie soit renvoyée à l'identique dans toutes les directions, sans aucune direction privilégiée. On a alors le ''material'' le plus simple qui soit, appelé un '''''diffuse material'''''. Vu que la lumière est réfléchie à l'identique dans toutes les directions, elle sera identique peu importe où on place la caméra. La lumière finale ne dépend donc que des propriété de la surface, que de sa couleur. En clair, il suffit de donner une '''couleur diffuse''' à chaque sommet. La couleur diffuse est simplement multipliée par le terme géométrique, pour obtenir la lumière réfléchie finale. Rien de plus, rien de moins. Cela donne l'équation suivante, avec les termes suivants : * L est le vecteur pour la lumière incidente ; * N est la normale du sommet ; * I est l'intensité de la source de lumière ; * <math>C_d</math> est la couleur diffuse. : <math>\text{Illumination diffuse} = C_d \times \left[ I \times (\vec{N} \cdot \vec{L}) \right]</math> Rajoutons maintenant l'effet de la lumière ambiante à un ''material'' de ce genre. Pour rappel, la lumière ambiante vient de toutes les directions à part égale, ce qui fait que son angle d'incidence n'a donc pas d'effet. L'intensité de la lumière ambiante est déterminée lors de la création de la scène 3D, c'est une constante qui n'a pas à être calculée. Pour obtenir l'effet de la lumière ambiante sur un objet, il suffit de multiplier sa couleur diffuse par l'intensité de la lumière ambiante. Cependant, de nombreux moteurs de jeux ajoutent une '''couleur ambiante''', différente de la couleur diffuse. : <math>\text{Illumination ambiante} = C_a \times I_a</math> avec <math>C_a</math> la couleur ambiante du point de surface et <math>I_a</math> l'intensité de la lumière ambiante. En plus de la réflexion diffuse parfaite, de nombreux matériaux ajoutent une '''réflexion spéculaire''', qui n'est pas exactement la réflexion parfaite, en est très proche. Les rayons réfléchis sont très proches de la direction de réflexion parfaite, et s'atténuent très vite en s'en éloignant. Le résultat ressemble à une sorte de petit "point blanc", très lumineux, orienté vers la source de lumière, appelé le '''''specular highlight'''''. La réflexion diffuse est prédominante pour les matériaux rugueux, alors que la réflexion spéculaire est dominante sur les matériaux métalliques ou très lisses. [[File:Phong components version 4.png|centre|vignette|upright=3.0|Couleurs utilisées dans l'algorithme de Phong.]] [[File:Phong Vectors.svg|vignette|Vecteurs utilisés dans l'algorithme de Phong (et dans le calcul de l'éclairage, de manière générale).]] Pour calculer la réflexion spéculaire, il faut d'abord connaitre le vecteur pour la réflexion parfaite, que nous noterons R dans ce qui suit. Le vecteur R peut se calculer avec la formule ci-dessous : : <math>\vec{R} = 2 (\vec{L} \cdot \vec{N}) \times \vec{N} - \vec{L} </math> La réflexion spéculaire dépend de l'angle entre la direction du regard et la normale : plus celui-ci est proche de l'angle de réflexion parfaite, plus la réflexion spéculaire sera intense. Le vecteur pour la direction du regard sera noté V, pour vue ou vision. La réflexion spéculaire est une fonction qui dépend de l'angle entre les vecteurs R et V. Le calcul de la réflexion spéculaire utilise une '''couleur spéculaire''', qui est l'équivalent de la couleur diffuse pour la réflexion spéculaire. : <math>\text{BRDF spéculaire} = C_s \times f(\vec{R} \cdot \vec{V}) </math> La fonction varie grandement d'un modèle de calcul spéculaire à l'autre. Aussi, je ne rentre pas dans le détail. L'essentiel est que vous compreniez que le calcul de l'éclairage utilise de nombreux calculs géométriques, réalisés avec des produits scalaires. Les calculs géométriques utilisent la couleur d'un sommet, la normale du sommet, et le vecteur de la lumière incidente. Les autres informations sont calculées à l'exécution. ===Les algorithmes d'éclairage basiques : par triangle, par sommet et par pixel=== Dans tout ce qui a été dit précédemment, l'éclairage est calculé pour chaque sommet. Il attribue une illumination/couleur à chaque sommet de la scène 3D, ce qui fait qu'on parle d''''éclairage par sommet''', ou ''vertex lighting''. Il est assez rudimentaire et donne un éclairage très brut, mais il peut être réalisé avant l'étape de rastérisation. Mais une fois qu'on a obtenu la couleur des sommets, reste à colorier les triangles. Et pour cela, il y a deux manières de faire, qui sont appelées l'éclairage plat et l'éclairage de Gouraud. [[File:D3D Shading Triangles.png|vignette|Dans ce dessin, le triangle a un sommet de couleur bleu foncé, un autre de couleur rouge et un autre de couleur bleu clair. L’interpolation plate et de Gouraud donnent des résultats bien différents.]] L''''éclairage plat''' calcule l'éclairage triangle par triangle. Il y a plusieurs manières de faire pour ça, mais la plus simple colorie un triangle avec la couleur moyenne des trois sommets. Une autre possibilité fait les calculs d'éclairage triangle par triangle, en utilisant une normale par triangle et non par sommet, idem pour les couleurs ambiante/spéculaire/diffuse. Mais c'est plus rare car cela demande de placer la normale quelque part dans le triangle, ce qui rajoute des informations. L''''éclairage de Gouraud''' effectue lui aussi une moyenne de la couleur de chaque sommet, sauf que celle-ci est pondérée par la distance du sommet avec le pixel. Plus le pixel est loin d'un sommet, plus son coefficient est petit. Typiquement, le coefficient varie entre 0 et 1 : de 1 si le pixel est sur le sommet, à 0 si le pixel est sur un des sommets adjacents. La moyenne effectuée est généralement une interpolation bilinéaire, mais n'importe quel algorithme d'interpolation peut marcher, qu'il soit simplement linéaire, bilinéaire, cubique, hyperbolique. L'étape d'interpolation est prise en charge par l'étape de rastérisation, qui effectue cette moyenne automatiquement. L'éclairage par sommet a eu son heure de gloire, mais il est maintenant remplacé par l''''éclairage par pixel''' (''per-pixel lighting''), qui calcule l'éclairage pixel par pixel. En clair, l’éclairage est finalisé après l'étape de rastérisation, il ne se fait pas qu'au niveau de la géométrie. Il existe plusieurs types d'éclairage par pixel, mais on peut les classer en deux grands types : l'éclairage de Phong et le ''bump/normal mapping''. L''''éclairage de Phong''' calcule l'éclairage pixel par pixel. Avec cet algorithme, la géométrie n'est pas éclairée : les couleurs des sommets ne sont pas calculées. A la place, les normales sont envoyées à l'étape de rastérisation, qui effectue une opération d'interpolation, qui renvoie une normale pour chaque pixel. Les calculs d'éclairage utilisent alors ces normales pour faire les calculs d'éclairage pour chaque pixel. La technique du '''''normal mapping''''' est assez simple à expliquer, sans compter que plusieurs cartes graphiques l'ont implémentée directement dans leurs circuits. Là où l'éclairage de Phong interpole les normales pour chaque pixel, le ''normal-mapping'' précalcule les normales d'une surface dans une texture, appelée la ''normal-map''. Lors du rendu 3D, la carte graphique lit les normales adéquates directement depuis cette texture, puis fait les calculs d'éclairage avec. Le ''normal mapping'' permettent d'ajouter du relief et des détails sur des surfaces planes en jouant sur l'éclairage. Il permet de simplifier grandement la géométrie rendue, tout en utilisant l'éclairage pour compenser. [[File:WallSimpleAndNormalMapping.png|centre|vignette|upright=2|Différence sans et avec ''normal-mapping''.]] L'éclairage par pixel donne une qualité d'image supérieure à l'éclairage par sommet, mais il est aussi plus gourmand. Mais il est devenu la norme sur les jeux vidéos actuels. La différence entre l'éclairage par pixel et par sommet se voit assez facilement à l'écran. L'éclairage plat donne un éclairage assez carré, avec des frontières assez nettes. L'éclairage de Gouraud donne des ombres plus lisses, dans une certaine mesure, mais pèche à rendre correctement les reflets spéculaires. L'éclairage de Phong est de meilleure qualité, surtout pour les reflets spéculaires. es trois algorithmes peuvent être implémentés soit dans la carte graphique, soit en logiciel. Nous verrons comment les cartes graphiques peuvent implémenter ces algorithmes, dans les deux prochains chapitres. {| |- |[[File:Per face lighting.png|vignette|upright=1|Flat shading]] |[[File:Per vertex lighting.png|vignette|upright=1|Gouraud Shading]] |[[File:Per fragment lighting.png|vignette|upright=1|Phong Shading]] |- |[[File:Per face lighting example.png|vignette|upright=1|Flat shading]] |[[File:Per vertex lighting example.png|vignette|upright=1|Gouraud Shading]] |[[File:Per fragment lighting example.png|vignette|upright=1|Phong Shading]] |} ===Les ''shaders'' : des programmes exécutés sur le GPU=== Maintenant que nous venons de voir les algorithmes d'éclairages, il est temps de voir comment les réaliser sur une carte graphique. Nous venons de voir qu'il y a une différence entre l'éclairage par pixel et par sommet. Intuitivement, l'éclairage par sommet devrait se faire avec les calculs géométriques, alors que l'éclairage par pixel devrait se faire après avoir appliqué les textures. Les toutes premières cartes graphiques ne géraient ni l'éclairage par sommet, ni l'éclairage par pixel. Elles laissaient les calculs géométriques au CPU. Par la suite, la Geforce 256 a intégré '''circuit de ''Transform & Lightning''''', qui s'occupait de tous les calculs géométriques, éclairage par sommet inclus (d'où le L de T&L). Elle gérait alors l'éclairage par sommet, mais un algorithme particulier, qui n'était pas très flexible. Il ne gérait que des ''material'' bien précis (des ''Phong materials''), rien de plus. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Unité de T&L : géométrie | Rastérisation | Placage de textures | ''Raster Operations Pipeline'' |} L'amélioration suivante est venue sur la Geforce 3 : l'unité de T&L est devenue programmable. Au vu le grand nombre d'algorithmes d'éclairages possibles et le grand nombre de ''materials'' possibles, c'était la seule voie possibles. Les programmeurs pouvaient programmer leurs propres algorithmes d'éclairage par sommet, même s'ils devaient aussi programmer les étapes de transformation et de projection. Mais nous détaillerons cela dans un chapitre dédié sur l'historique des GPUs. Ce qui est important est que la Geforce 3 a introduit une fonctionnalité absolument cruciale pour le rendu 3D moderne : les '''''shaders'''''. Il s'agit de programmes informatiques exécutés par la carte graphique, qui servaient initialement à coder des algorithmes d'éclairage. D'où leur nom : ''shader'' pour ''shading'' (éclairage en anglais). Cependant, l'usage modernes des shaders dépasse le cadre des algorithmes d'éclairage. L'avantage est que cela simplifie grandement l'implémentation des algorithmes d'éclairage. Pas besoin de les intégrer dans la carte graphique pour les utiliser, pas besoin d'un circuit distinct pour chaque algorithme. Sans shaders, si la carte graphique ne gère pas un algorithme d'éclairage, on ne peut pas l'utiliser. A la rigueur, il est parfois possible de l'émuler avec des contournements logiciels, mais au prix de performances souvent désastreuses. Avec des shaders, il est possible de programmer l'algorithme d'éclairage de notre choix, pour l'exécuter sur la carte graphique, avec des performances plus que convenables. [[File:Implémentation de l'éclairage sur les cartes graphiques.png|vignette|Implémentation de l'éclairage sur les cartes graphiques]] Il existe plusieurs types de shaders, mais les deux principaux sont les '''''vertex shaders''''' et les '''''pixel shaders'''''. Les pixels shaders s'occupent de l'éclairage par pixel, leur nom est assez parlent. Les vertex shaders s'occupent de l'éclairage par sommet, mais aussi des étapes de transformation/projection. Je parle bien des trois étapes de transformation vues plus haut, qui effectuent des calculs de transformation de coordonnées avec des matrices. La raison à cela est que les calculs de transformation ressemblent beaucoup aux calculs d'éclairage par sommet. Ils impliquent tous deux des calculs vectoriels, comme des produits scalaires et des produits vectoriels, qui agissent sur des sommets/triangles. Si la carte graphique incorpore un processeur de shader capable de faire de tels calculs, alors il peut servir pour les deux. Pour implémenter les shaders, il a fallu ajouter des processeurs à la carte graphique. Les processeurs en question exécutent les shaders, ils peuvent lire ou écrire dans des textures, mais ne font rien d'autres. Les ''vertex shaders'' font tout ce qui a trait à la géométrie, ils remplacent l'unité de T&L. Les pixels shaders sont entre la rastérisation et les ROPs, ils sont très liés à l'unité de texture. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | rowspan="2" class="f_rouge" | ''Vertex shader'' | rowspan="2" | Rastérisation | Placage de textures | rowspan="2" |''Raster Operations Pipeline'' |- | class="f_rouge" | ''Pixel shader'' |} <noinclude> {{NavChapitre | book=Les cartes graphiques | prev=Les cartes d'affichage des anciens PC | prevText=Les cartes d'affichage des anciens PC | next=Avant les GPUs : les cartes accélératrices 3D | nextText=Avant les GPUs : les cartes accélératrices 3D }}{{autocat}} </noinclude> 83i30gdtzdasyn9zahtnc83zflu1yd1 765148 765147 2026-04-26T20:12:37Z Mewtow 31375 /* Les algorithmes d'éclairage basiques : par triangle, par sommet et par pixel */ 765148 wikitext text/x-wiki Le premier jeu à utiliser de la "vraie 3D" texturée fut le jeu Quake, premier du nom. Et depuis sa sortie, la grande majorité des jeux vidéo utilisent de la 3D, même s'il existe encore quelques jeux en 2D. Face à la prolifération des jeux vidéo en 3D, les fabricants de cartes graphiques ont inventé les cartes accélératrices 3D, des cartes vidéo capables d'accélérer le rendu en 3D. Dans ce chapitre, nous allons voir comment elles fonctionnent et comment elles ont évolué dans le temps. Pour comprendre comment celles-ci fonctionnent, il faut faire quelques rapides rappels sur les bases du rendu 3D. ==Les bases du rendu 3D== Une '''scène 3D''' est composée d'un espace en trois dimensions, dans laquelle le moteur d’un jeu vidéo place des objets et les fait bouger. Cette scène est, en première approche, un simple parallélogramme. Un des coins de ce parallélogramme sert d’origine à un système de coordonnées : il est à la position (0, 0, 0), et les axes partent de ce point en suivant les arêtes. Les objets seront placés à des coordonnées bien précises dans ce parallélogramme. ===Les objets 3D et leur géométrie=== <noinclude>[[File:Dolphin triangle mesh.png|vignette|Illustration d'un dauphin, représenté avec des triangles.]]</noinclude> Dans la quasi-totalité des jeux vidéo actuels, les objets et la scène 3D sont modélisés par un assemblage de triangles collés les uns aux autres, ce qui porte le nom de '''maillage''', (''mesh'' en anglais). Il a été tenté dans le passé d'utiliser des quadrilatères (rendu dit en ''quad'') ou d'autres polygones, mais les contraintes techniques ont fait que ces solutions n'ont pas été retenues. [[File:CG WIKI.jpg|centre|vignette|upright=2|Exemple de modèle 3D.]] Les modèles 3D sont définis par leurs sommets, aussi appelés '''vertices''' dans le domaine du rendu 3D. Chaque sommet possède trois coordonnées, qui indiquent sa position dans la scène 3D : abscisse, ordonnée, profondeur. Les sommets sont regroupés en triangles, qui sont formés en combinant trois sommets entre eux. Les anciennes cartes graphiques géraient aussi d'autres formes géométriques, comme des points, des lignes, ou des quadrilatères. Les quadrilatères étaient appelés des ''quads'', et ce terme reviendra occasionnellement dans ce cours. De telles formes basiques, gérées nativement, sont appelées des '''primitives'''. La représentation exacte d'un objet est donc une liste plus ou moins structurée de sommets. La liste doit préciser les coordonnées de chaque sommet, ainsi que comment les relier pour former des triangles. Pour cela, l'objet est représenté par une structure qui contient la liste des sommets, mais aussi de quoi savoir quels sont les sommets reliés entre eux par un segment. Nous en dirons plus dans le chapitre sur le rendu de la géométrie. ===La caméra : le point de vue depuis l'écran=== Outre les objets proprement dit, on trouve une '''caméra''', qui représente les yeux du joueur. Cette caméra est définie au minimum par : * une position ; * par la direction du regard (un vecteur). A la caméra, il faut ajouter tout ce qui permet de déterminer le '''champ de vision'''. Le champ de vision contient tout ce qui est visible à l'écran. Et sa forme dépend de la perspective utilisée. Dans le cas le plus courant dans les jeux vidéos en 3D, il correspond à une '''pyramide de vision''' dont la pointe est la caméra, et dont les faces sont délimitées par les bords de l'écran. A l'intérieur de la pyramide, il y a un rectangle qui représente l'écran du joueur, appelé le '''''viewport'''''. <noinclude>[[File:ViewFrustum.jpg|centre|vignette|upright=2|Caméra.]]</noinclude> [[File:ViewFrustum.svg|vignette|upright=1|Volume délimité par la caméra (''view frustum'').]] La majorité des jeux vidéos ajoutent deux plans : * un ''near plane'' en-deça duquel les objets ne sont pas affichés. Il élimine du champ de vision les objets trop proches. * Un ''far plane'', un '''plan limite''' au-delà duquel on ne voit plus les objets. Il élimine les objets trop lointains. Avec ces deux plans, le champ de vision de la caméra est donc un volume en forme de pyramide tronquée, appelé le '''''view frustum'''''. Le tout est parfois appelée, bien que par abus de langage, la pyramide de vision. Avec d'autres perspectives moins utilisées, le ''view frustum'' est un pavé, mais nous n'en parlerons pas plus dans le cadre de ce cours car elles ne sont presque pas utilisés dans les jeux vidéos actuels. ===Les textures=== Tout objet à rendre en 3D est donc composé d'un assemblage de triangles, et ceux-ci sont éclairés et coloriés par divers algorithmes. Pour rajouter de la couleur, les objets sont recouverts par des '''textures''', des images qui servent de papier peint à un objet. Un objet géométrique est donc recouvert par une ou plusieurs textures qui permettent de le colorier ou de lui appliquer du relief. [[File:Texture+Mapping.jpg|centre|vignette|upright=2|Texture Mapping]] Notons que les textures sont des images comme les autres, codées pixel par pixel. Pour faire la différence entre les pixels de l'écran et les pixels d'une texture, on appelle ces derniers des '''texels'''. Ce terme est assez important, aussi profitez-en pour le mémoriser, nous le réutiliserons dans quelques chapitres. Un autre point lié au fait que les textures sont des images est leur compression, leur format. N'allez pas croire que les textures sont stockées dans un fichier .jpg, .png ou tout autre format de ce genre. Les textures utilisent des formats spécialisés, comme le DXTC1, le S3TC ou d'autres, plus adaptés à leur rôle de texture. Mais qu'il s'agisse d'images normales (.jpg, .png ou autres) ou de textures, toutes sont compressées. Les textures sont compressées pour prendre moins de mémoire. Songez que la compression de texture est terriblement efficace, souvent capable de diviser par 6 la mémoire occupée par une texture. S'en est au point où les textures restent compressées sur le disque dur, mais aussi dans la mémoire vidéo ! Nous en reparlerons dans le chapitre sur la mémoire d'une carte graphique. Plaquer une texture sur un objet peut se faire de deux manières, qui portent les noms de placage de texture inverse et direct. Le placage de texture direct a été utilisé au tout début de la 3D, sur des bornes d'arcade et les consoles de jeu 3DO, PS1, Sega Saturn. De nos jours, on utilise uniquement la technique de placage de texture inverse. Les deux seront décrites dans le détail plus bas. ===La différence entre rastérisation et lancer de rayons=== Les techniques de rendu 3D sont nombreuses, mais on peut les classer en deux grands types : le ''lancer de rayons'' et la ''rasterization''. Sans décrire les deux techniques, sachez cependant que le lancer de rayon n'est pas beaucoup utilisé pour les jeux vidéo. Il est surtout utilisé dans la production de films d'animation, d'effets spéciaux, ou d'autres rendu spéciaux. Dans les jeux vidéos, il est surtout utilisé pour quelques effets graphiques, la rasterization restant le mode de rendu principal. La raison principale est que le lancer de rayons demande beaucoup de puissance de calcul. Une autre raison est que créer des cartes accélératrices pour le lancer de rayons n'est pas simple. Il a existé des cartes accélératrices permettant d'accélérer le rendu en lancer de rayons, mais elles sont restées confidentielles. Les cartes graphiques modernes incorporent quelques circuits pour accélérer le lancer de rayons, mais ils restent d'un usage marginal et servent de compléments au rendu par rastérization. Un chapitre entier sera dédié aux cartes accélératrices de lancer de rayons et nous verrons pourquoi le lancer de rayons est difficile à implémenter avec des performances convenables, ce qui explique que les jeux vidéo utilisent la ''rasterization''. La rastérisation est structurée autour de trois étapes principales : * Une étape purement logicielle, effectuée par le processeur, où le moteur physique calcule la géométrie de la scène 3D. * Une étape de '''traitement de la géométrie''', qui gère tout ce qui a trait aux sommets et triangles. * Une étape de '''rastérisation''' qui détermine sur quels pixels de l'écran est affiché le triangle. * Une étape de '''traitement des pixels''', qui colorie les pixels et gère les textures. [[File:Graphics pipeline 2 en.svg|centre|vignette|upright=2.5|Pipeline graphique basique.]] Il existe plusieurs rendus différents et la rastérisation ne se fait pas de la même manière selon le rendu utilisé. Il existe des rendus sans textures, d'autres avec, d'autres avec éclairage, d'autres sans, etc. Par contre, l'étape de calcul de la géométrie est la même quel que soit le rendu ! Mieux : le calcul de la géométrie se fait de la même manière entre rastérisation et lancer de rayons, il est le même quelle que soit la technique de rendu 3D utilisée. Les trois étapes précédentes sont réalisées dans des circuits ou processeurs séparés, comme on le verra plus tard. Et cela permet d'utiliser la technique dite du '''pipeline'''. Concrètement, supposons que la carte graphique traite les données par paquets de triangles (en réalité, c'est des paquets de sommets, mais passons). L'étape de traitement de la géométrie peut travailler sur un paquet de triangle, pendant que le paquet précédent est dans l'étape de rastérisation, et que le paquet encore précédent est en train de traiter ses pixels. Cela permet de traiter trois paquets de triangles en même temps, mais à des états d'avancements différents. Mieux que cela : le traitement de la géométrie est lui-même composé d'une succession de sous-étapes, la rasterisation est elle-même découpée en plusieurs sous-étapes, et ainsi de suite. Le nombre d'étapes pour une carte graphique moderne dépasse la dizaine. La rastérisation calcule un rendu 3D avec une suite d'étapes consécutives qui doivent s'enchainer dans un ordre bien précis. L'ensemble de ces étapes est appelé le '''pipeline graphique''', qui sera détaillé dans ce qui suit. ==Le calcul de la géométrie== Le calcul de la géométrie regroupe plusieurs manipulations différentes. La principale demande juste de placer les modèles 3D dans la scène, de placer les objets dans le monde. Puis, il faut centrer la scène 3D sur la caméra. Les deux changements ont pour point commun de demander des changements de repères. Par changement de repères, on veut dire que l'on passe d'un système de coordonnées à un autre. En tout, il existe trois changements de repères distincts qui sont regroupés dans l''''étape de transformation''' : un premier qui place chaque objet 3D dans la scène 3D, un autre qui centre la scène du point de vue de la caméra, et un autre qui corrige la perspective. ===Les trois étapes de transformation=== La première étape place les objets 3D dans la scène 3D. Un modèle 3D est représentée par un ensemble de sommets, qui sont reliés pour former sa surface. Les données du modèle 3D indiquent, pour chaque sommet, sa position par rapport au centre de l'objet qui a les coordonnées (0, 0, 0). La première étape place l'objet 3D à une position dans la scène 3D, déterminée par le moteur physique, qui a des coordonnées (X, Y, Z). Une fois placé dans la scène 3D, le centre de l'objet passe donc des coordonnées (0, 0, 0) aux coordonnées (X, Y, Z) et tous les sommets de l'objet doivent être mis à jour. De plus, l'objet a une certaine orientation : il faut aussi le faire tourner. Enfin, l'objet peut aussi subir une mise à l'échelle : on peut le gonfler ou le faire rapetisser, du moment que cela ne modifie pas sa forme, mais simplement sa taille. En clair, le modèle 3D subit une translation, une rotation et une mise à l'échelle, les trois impliquant une modification des coordonnées des sommets.. [[File:Similarity and congruence transformations.svg|centre|vignette|upright=1.5|Transformations géométriques possibles pour chaque triangle.]] Une fois le placement des différents objets effectué, la carte graphique effectue un changement de coordonnées pour centrer le monde sur la caméra. Au lieu de considérer un des bords de la scène 3D comme étant le point de coordonnées (0, 0, 0), il va passer dans le référentiel de la caméra. Après cette transformation, le point de coordonnées (0, 0, 0) sera la caméra. La direction de la vue du joueur sera alignée avec l'axe de la profondeur (l'axe Z). [[File:View transform.svg|centre|vignette|upright=2|Étape de transformation dans un environnement en deux dimensions : avant et après. On voit que l'on centre le monde sur la position de la caméra et dans sa direction.]] Enfin, il faut aussi corriger la perspective, ce qui est le fait de l'étape de projection, qui modifie la forme du ''view frustum'' sans en modifier le contenu. Différents types de perspective existent et celles-ci ont un impact différent les unes des autres sur le ''view frustum''. Dans le cas qui nous intéresse, le ''view frustum'' passe d’une forme de trapèze tridimensionnel à une forme de pavé dont l'écran est une des faces. ===Les changements de coordonnées se font via des multiplications de matrices=== Les trois étapes précédentes demande de faire des changements de coordonnées, chaque sommet voyant ses coordonnées remplacées par de nouvelles. Or, un changement de coordonnée s'effectue assez simplement, avec des matrices, à savoir des tableaux organisés en lignes et en colonnes avec un nombre dans chaque case. Un changement de coordonnées se fait simplement en multipliant le vecteur (X, Y, Z) des coordonnées d'un sommet par une matrice adéquate. Il existe des matrices pour la translation, la mise à l'échelle, d'autres pour la rotation, une autre pour la transformation de la caméra, une autre pour l'étape de projection, etc. Un changement de coordonnée s'effectue assez simplement en multipliant le vecteur-coordonnées (X, Y, Z) d'un sommet par une matrice adéquate. Un petit problème est que les matrices qui le permettent sont des matrices avec 4 lignes et 4 colonnes. Or, la multiplication demande que le nombre de coordonnées du vecteur soit égal au nombre de colonnes. Pour résoudre ce petit problème, on ajoute une 4éme coordonnée aux sommets, la coordonnée homogène, qui ne sert à rien, et est souvent mise à 1, par défaut. Mais oublions ce détail. Il se trouve que multiplier des matrices amène certaines simplifications. Au lieu de faire plusieurs multiplications de matrices, il est possible de fusionner les matrices en une seule, ce qui permet de simplifier les calculs. Ce qui fait que le placement des objets, changement de repère pour centrer la caméra, et d'autres traitements forts différents sont regroupés ensemble. Le traitement de la géométrie implique, sans surprise, des calculs de géométrie dans l'espace. Et cela implique des opérations mathématiques aux noms barbares : produits scalaires, produits vectoriels, et autres calculs impliquant des vecteurs et/ou des matrices. Et les calculs vectoriels/matriciels impliquent beaucoup d'additions, de soustractions, de multiplications, de division, mais aussi des opérations plus complexes : calculs trigonométriques, racines carrées, inverse d'une racine carrée, etc. Au final, un simple processeur peut faire ce genre de calculs, si on lui fournit le programme adéquat, l'implémentation est assez aisée. Mais on peut aussi implémenter le tout avec un circuit spécialisé, non-programmable. Les deux solutions sont possibles, tant que le circuit dispose d'assez de puissance de calcul. Les cartes graphiques anciennes contenaient un ou plusieurs circuits de multiplication de matrices spécialisés dans l'étape de transformation. Chacun de ces circuits prend un sommet et renvoie le sommet transformé. Ils sont composés d'un gros paquet de multiplieurs et d'additionneurs flottants. Pour plus d'efficacité, les cartes graphiques comportent plusieurs de ces circuits, afin de pouvoir traiter plusieurs sommets en même temps. ==L'élimination des surfaces cachées== Un point important du rendu 3D est que ce que certaines portions de la scène 3D ne sont pas visibles depuis la caméra. Et idéalement, les portions de la scène 3D qui ne sont pas visibles à l'écran ne doivent pas être calculées. A quoi bon calculer des choses qui ne seront pas affichées ? Ce serait gâcher de la puissance de calcul. Et pour cela, de nombreuses optimisations visent à éliminer les calculs inutiles. Elles sont regroupées sous les termes de '''''clipping''''' ou de '''''culling'''''. La différence entre ''culling'' et ''clipping'' n'est pas fixée et la terminologie n'est pas claire. Dans ce qui va suivre, nous n'utiliserons que le terme ''culling''. Les cartes graphiques modernes embarquent diverses méthodes de ''culling'' pour abandonner les calculs quand elles s’aperçoivent que ceux-ci portent sur une partie non-affichée de l'image. Cela fait des économies de puissance de calcul assez appréciables et un gain en performance assez important. Précisons que le ''culling'' peut être plus ou moins précoce suivant le type de rendu 3D utilisé, mais nous verrons cela dans la suite du chapitre. ===Les différentes formes de ''culling''/''clipping''=== La première forme de ''culling'' est le '''''view frustum culling''''', dont le nom indique qu'il s'agit de l'élimination de tout ce qui est situé en-dehors du ''view frustum''. Ce qui est en-dehors du champ de vision de la caméra n'est pas affiché à l'écran n'est pas calculé ou rendu, dans une certaine mesure. Le ''view frustum culling'' est assez trivial : il suffit d'éliminer ce qui n'est pas dans le ''view frustum'' avec quelques calculs de coordonnées assez simples. Quelques subtilités surviennent quand un triangle est partiellement dans le ''view frustrum'', ce qui arrive parfois si le triangle est sur un bord de l'écran. Mais rien d'insurmontable. [[File:View frustum culling.svg|centre|vignette|upright=1|''View frustum culling'' : les parties potentiellement visibles sont en vert, celles invisibles en rouge et celles partiellement visibles en bleu.]] Les autres formes de ''culling'' visent à éliminer ce qui est dans le ''view frustum'', mais qui n'est pas visible depuis la caméra. Pensez à des objets cachés par un autre objet plus proche, par exemple. Ou encore, pensez aux faces à l'arrière d'un objet opaque qui sont cachées par l'avant. Ces deux cas correspondent à deux types de ''culling''. L'élimination des objets masqués par d'autres est appelé l'''occlusion culling''. L'élimination des parties arrières d'un objet est appelé le ''back-face culling''. Dans les deux cas, nous parlerons d''''élimination des surfaces cachées'''. [[File:Occlusion culling example PL.svg|centre|vignette|''Occlusion culling'' : les objets en bleu sont visibles, ceux en rouge sont masqués par les objets en bleu.]] Le lancer de rayons n'a pas besoin d'éliminer les surfaces cachées, il ne calcule que les surfaces visibles. Par contre, la rastérisation demande d'éliminer les surfaces cachées. Sans cela, le rendu est incorrect dans le pire des cas, ou alors le rendu calcule des surfaces invisibles pour rien. Il existe de nombreux algorithmes logiciels pour implémenter l'élimination des surfaces cachées, mais la carte graphique peut aussi s'en charger. L'''occlusion culling'' demande de connaitre la distance à la caméra de chaque triangle. La distance à la caméra est appelée la '''profondeur''' du triangle. Elle est déterminée à l'étape de rastérisation et est calculée à chaque sommet. Lors de la rastérisation, chaque sommet se voit attribuer trois coordonnées : deux coordonnées x et y qui indiquent sa position à l'écran, et une coordonnée de profondeur notée z. ===L'algorithme du peintre=== Pour éliminer les surfaces cachées, la solution la plus simple consiste simplement à rendre les triangles du plus lointain au plus proche. L'idée est que si deux triangles se recouvrent totalement ou partiellement, on doit dessiner celui qui est derrière, puis celui qui est devant. Le dessin du second va recouvrir le premier. Quelque chose qui devrait vous rappeler le rendu 2D, où les sprites sont rendus du plus lointain au plus proche. Il ne s'agit ni plus ni moins que de l''''algorithme du peintre'''. [[File:Polygons cross.svg|vignette|Polygons cross]] Un problème est que la solution ne marche pas avec certaines configurations particulières, dans le cas où des polygones un peu complexes se chevauchent plusieurs fois. Il se présente rarement dans un rendu 3D normal, mais c'est quand même un cas qu'il faut gérer. Le problème est suffisant pour que cette solution ne soit plus utilisée dans le rendu 3D normal. Un autre problème est que l'algorithme demande de trier les triangles d'une scène 3D selon leur profondeur, du plus profond au moins profond. Et les cartes graphiques n'aiment pas ça, que ce soit les anciennes cartes graphiques comme les modernes. Il s'agit généralement d'une tâche qui est réalisée par le processeur, le CPU, qui est plus efficace que le GPU pour trier des trucs. Aussi, l'algorithme du peintre était utilisé sur d'anciennes cartes graphiques, qui ne géraient pas la géométrie mais seulement les textures et quelques effets de post-processing. Avec ces GPU, les jeux vidéo calculaient la géométrie et la triait sur le CPU, puis effectuaient le reste de la rastérisation sur le GPU. Les anciens jeux en 2.5D comme DOOM ou les DOOM-like, utilisaient une amélioration de l'algorithme du peintre. L'amélioration variait suivant le moteur de jeu utilisé, et donnait soit une technique dite de ''portal rendering'', soit un système de ''Binary Space Partionning'', assez complexes et difficiles à expliquer. Mais il ne s'agissait pas de jeux en 3D, les maps de ces jeux avaient des contraintes qui rendaient cette technique utilisable. Ils n'avaient pas de polygones qui se chevauchent, notamment. ===Le tampon de profondeur=== [[File:Z-buffer no text.jpg|vignette|Z-buffer correspondant à un rendu]] Une autre solution utilise ce qu'on appelle un '''tampon de profondeur''', aussi appelé un ''z-buffer''. Il s'agit d'un tableau, stocké en mémoire vidéo, qui mémorise la coordonnée z de l'objet le plus proche pour chaque pixel. Par défaut, ce tampon de profondeur est initialisé avec la valeur de profondeur maximale, celle du ''far plane'' du ''viewfrustum''. Au fur et à mesure que les objets seront calculés, le tampon de profondeur est mis à jour, conservant ainsi la trace de l'objet le plus proche de la caméra. Si jamais un triangle a une coordonnée z plus grande que celle du tampon de profondeur, cela veut dire qu'il est situé derrière un objet déjà rendu. Il est éliminé (sauf si transparence il y a) et le tampon de profondeur n'a pas à être mis à jour. Dans le cas contraire, l'objet est plus près de la caméra et sa coordonnée z remplace l'ancienne valeur z dans le tampon de profondeur. [[File:Z-buffer.svg|centre|vignette|upright=2.0|Illustration du processus de mise à jour du Z-buffer.]] Il existe des techniques alternatives pour coder la coordonnée de profondeur, qui se distinguent par le fait que la coordonnée z n'est pas proportionnelle à la distance entre le fragment et la caméra. Mais il s'agit là de détails assez mathématiques que je me permets de passer sous silence. Dans la suite de ce cours, nous allons juste parler de profondeur pour regrouper toutes ces techniques, conventionnelles ou alternatives. Toutes les cartes graphiques modernes utilisent un système de ''z-buffer''. C'est la seule solution pour avoir des performances dignes de ce nom. Il faut cependant noter qu'elles utilisent des tampons de profondeur légèrement modifiés, qui ne mémorisent pas la coordonnée de profondeur, mais une valeur dérivée. Pour simplifier, ils ne mémorisent pas la coordonnée de profondeur z, mais son inverse 1/z. Les raisons à cela ne peuvent pas encore être expliquées à ce moment du cours, aussi nous allons simplement dire que c'est une histoire de correction de perspective. Les coordonnées z et 1/z sont codées sur quelques bits, allant de 16 bits pour les anciennes cartes graphiques, à 24/32 bits pour les cartes plus récentes. De nos jours, les Z-buffer de 16 bits sont abandonnés et toutes les cartes graphiques utilisent des coordonnées z de 24 à 32 bits. La raison est que les Z-buffer de 16 bits ont une précision insuffisante, ce qui fait que des artefacts peuvent survenir. Si deux objets sont suffisamment proches, le tampon de profondeur n'a pas la précision suffisante pour discriminer les deux objets. Pour lui, les deux objets sont à la même place. Conséquence : il faut bien choisir un des deux objets et ce choix se fait pixel par pixel, ce qui fait des artefacts visuels apparaissent. On parle alors de '''''z-fighting'''''. Voici ce que cela donne : [[File:Z-fighting.png|centre|vignette|Z-fighting]] Un défaut du tampon de profondeur est qu'il ne gère pas correctement les objets transparents. Dès que de la transparence est présente dans une scène 3D, le tampon de profondeur ne peut pas être utilisé. Une solution pour cela est de rendre une scène 3D en deux phases : une pour les objets opaques, une avec les objets transparents. La où on rend les objets opaques utilise le tampon de profondeur, mais il est désactivé lors de la seconde. ==La rastérisation et les textures== Dans cette section, nous allons voir ensemble l'étape de rastérisation et l'étape de traitement des pixels. La rastérisation est difficile à expliquer, surtout que son rôle exact dépend de la technique de rendu utilisée. Pour simplifier, elle projette un rendu en 3D sur un écran en 2D. Une autre explication tout aussi vague est qu'elle s'occupe la traduction des triangles en un affichage pixelisé à l'écran. Elle détermine à quoi ressemble la scène visible sur l'écran. C'est par exemple lors de cette étape que sont appliquées certaines techniques de ''culling'', qui éliminent les portions non-visibles de l'image, ainsi qu'une correction de la perspective et diverses opérations d'interpolation dont nous parlerons dans plusieurs chapitres. La rastérisation et placage de textures sont deux opérations très liées entre elles. Il existe deux manières principales pour lier les textures à la géométrie : la méthode directe et la méthode inverse (''UV Mapping''). Et les deux font que la rastérisation se fait de manière très différente. Précisons cependant que les rendus les plus simples n'utilisent pas de textures du tout. Ils se contentent de colorier les triangles, voire d'un simple rendu en fil de fer basé sur du tracé de lignes. Dans la suite de cette section, nous allons voir les quatre types de rendu principaux : le rendu en fils de fer, le rendu colorié, et deux rendus utilisant des textures. ===Le rendu en fil de fer=== [[File:Obj lineremoval.png|vignette|Rendu en fil de fer d'un objet 3D.]] Le '''rendu 3D en fils de fer''' est illustré ci-contre. Il s'agit d'un rendu assez ancien, utilisé au tout début de la 3D, sur des machines qu'on aurait du mal à appeler ordinateurs. Il se contente de tracer des lignes à l'écran, lignes qui connectent deux sommets, qui ne sont autres que les arêtes de la géométrie de la scène rendue. Le tout était suffisant pour réaliser quelques jeux vidéos rudimentaires. Les tout premiers jeux vidéos utilisaient ce rendu, l'un d'entre eux étant Maze War, le tout premier FPS. {| |[[File:Maze war.jpg|vignette|Maze war]] |[[File:Maze representation using wireframes 2022-01-10.gif|centre|vignette|Maze representation using wireframes 2022-01-10]] |} Le monde est calculé en 3D, il y a toujours un calcul de la géométrie, la scène est rastérisée normalement, les portions invisbles de l'image sont retirées, mais il n'y a pas d'application de textures après rastérisation. A la place, un algorithme de tracé de ligne trace les lignes à l'écran. Quand un triangle passe l'étape de rastérisation, l'étape de rastérisation fournit la position des trois sommets sur l'écran. En clair, elle fournit les coordonnées de trois pixels, un par sommet. A la suite, un algorithme de tracé de ligne trace trois lignes, une par paire de sommet. L'implémentation demande juste d'avoir une unité de calcul géométrique, une unité de rastérisation, et un VDC qui supporte le tracé de lignes. Elle est donc assez simple et ne demande pas de circuits de gestion des textures ni de ROP. Le VDC écrit directement dans le ''framebuffer'' les lignes à tracer. Il a existé des proto-cartes graphiques spécialisées dans ce genre de rendu, comme le '''''Line Drawing System-1''''' de l'entreprise Eans & Sutherland. Nous détaillerons son fonctionnement dans quelques chapitres. ===Le rendu à primitives colorées=== [[File:MiniFighter.png|vignette|upright=1|Exemple de rendu pouvant être obtenu avec des sommets colorés.]] Une amélioration du rendu précédent utilise des triangles/''quads'' coloriés. Chaque triangle ou ''quad'' est associé à une couleur, et cette couleur est dessinée sur le triangle/''quad''après la rastérisation. Le rendu est une amélioration du rendu en fils de fer. L'idée est que chaque triangle/''quad'' est associé à une couleur, qui est dessinée sur le triangle/''quad'' après la rastérisation. La technique est nommée ''colored vertices'' en anglais, nous parlerons de '''rendu à maillage coloré'''. [[File:Malla irregular de triángulos modelizando una superficie convexa.png|centre|vignette|upright=2|Maillage coloré.]] La couleur est propagée lors des calculs géométriques et de la rastérisation, sans subir de modifications. Une fois un rendu en fils de fer effectué, la couleur du triangle est récupérée. Le triangle/''quad'' rendu correspond à un triangle/''quad'' à l'écran. Et l'intérieur de ce triangle/''quad'' est colorié avec la couleur transmise. Pour cela, on utilise encore une fois une fonction du VDC : celle du remplissage de figure géométrique. Nous l’avions vu en parlant des VDC à accélération 2D, mais elle est souvent prise en charge par les ''blitters''. Ils peuvent remplir une figure géométrique avec une couleur unique, on réutilise cette fonction pour colorier le triangle/''quad''. L'étape de rastérisation fournit les coordonnées des sommets de la figure géométrique, le ''blitter'' les utilise pour colorier la figure géométrique. Niveau matériel, quelques bornes d'arcade ont utilisé ce rendu. La toute première borne d'arcade utilisant le rendu à maillage coloré est celle du jeu I Robot, d'Atari, sorti en 1983. Par la suite, dès 1988, les cartes d'arcades Namco System 21 et les bornes d'arcades Sega Model 1 utilisaient ce genre de rendu. On peut s'en rendre compte en regardant les graphismes des jeux tournant sur ces bornes d'arcade. Des jeux comme Virtua Racing, Virtua Fighter ou Virtua Formula sont assez parlants à ce niveau. Leurs graphismes sont assez anguleux et on voit qu'ils sont basés sur des triangles uniformément colorés. Pour ceux qui veulent en savoir plus sur la toute première borne d'arcade en rendu à maillage colorée, la borne ''I Robot'' d'Atari, voici une vidéo youtube à ce sujet : * [https://www.youtube.com/watch?v=6miEkPENsT0 I Robot d'Atari, le pionnier de la 3D Flat.] ===Le placage de textures direct=== Les deux rendus précédents sont très simples, mais n'utilisent pas de textures. Et il est temps de voir les deux rendus qui utilisent des textures. Il y en a deux types, appelés rendu avec placage de texture direct et indirect, nous allons voir le '''rendu par placage de texture direct''' en premier. Et nous l'appellerons ''rendu direct'' dans ce qui suit, pour simplifier les explications. L'idée est assez simple et peut utiliser aussi bien des triangles que des ''quads'', mais nous allons partir du principe qu'elle utilise des '''''quads''''', à savoir que les objets 3D sont composés de quadrilatères. Lorsqu'un ''quad'' est rastérisé, sa forme à l'écran est un rectangle déformé par la perspective. On obtient un rectangle si le ''quad'' est vu de face, un trapèze si on le voit de biais. Et le ''sprite'' doit être déformé de la même manière que le ''quad''. L'idée est que tout quad est associé à une texture, à un sprite. La figure géométrique qui correspond à un ''quad'' à l'écran est remplie non pas par une couleur uniforme, mais par un ''sprite'' rectangulaire. Il suffit techniquement de recopier le ''sprite'' à l'écran, c'est à dire dans la figure géométrique, au bon endroit dans le ''framebuffer''. Le rendu direct est en effet un intermédiaire entre rendu 2D à base de ''sprite'' et rendu 3D moderne. La géométrie est rendue en 3D pour générer des ''quads'', mais ces ''quads'' ne servent à guider la copie des sprites/textures dans le ''framebuffer''. [[File:TextureMapping.png|centre|vignette|upright=2|Exemple caricatural de placage de texture sur un ''quad''.]] La subtilité est que le sprite est déformé de manière à rentrer dans un quadrilatère, qui n'est pas forcément un rectangle à l'écran, mais est déformé par la perspective et son orientation en 3D. Le sprite doit être déformé de deux manières : il doit être agrandi/réduit en fonction de la taille de la figure affichée à l'écran, tourné en fonction de l'orientation du ''quad'', déformé pour gérer la perspective. Pour cela, il faut connaitre les coordonnées de profondeur de chaque bord d'un ''quad'', et de faire quelques calculs. N'importe quel VDC incluant un ''blitter'' avec une gestion du zoom/rotation des sprites peut le faire. : Si on veut avoir de beaux graphismes, il vaut mieux appliquer un filtre pour lisser le sprite envoyé dans le trapèze, filtre qui se résume à une opération d'interpolation et n'est pas très différent du filtrage de texture qui lisse les textures à l'écran. Un autre point est que les ''quads'' doivent être rendus du plus lointain au plus proche. Sans cela, on obtient rapidement des erreurs de rendu. L'idée est que si deux quads se chevauchent, on doit dessiner celui qui est derrière, puis celui qui est devant. Le dessin du second va recouvrir le premier. L'écriture du sprite du second quad écrasera les données du premier quad, pour les portions recouvertes, lors de l'écriture du sprite dans le ''framebuffer''. Quelque chose qui devrait vous rappeler le rendu 2D, où les sprites sont rendus du plus lointain au plus proche. Le rendu inverse utilise très souvent des triangles pour la géométrie, alors que le rendu direct a tendance à utiliser des ''quads'', mais il ne s'agit pas d'une différence stricte. L'usage de triangles/''quads'' peut se faire aussi bien avec un rendu direct comme avec un rendu inverse. Cependant, le rendu en ''quad'' se marie très bien au rendu direct, alors que le rendu en triangle colle mieux au rendu inverse. L'avantage de cette technique est qu'on parcourt les textures dans un ordre bien précis. Par exemple, on peut parcourir la texture ligne par ligne, l'exploiter par blocs de 4*4 pixels, etc. Et accéder à une texture de manière prédictible se marie bien avec l'usage de mémoires caches, ce qui est un avantage en matière de performances. Mais un même pixel du ''framebuffer'' est écrit plusieurs fois quand plusieurs quads se superposent, alors que le rendu inverse gère la situation avec une seule écriture (sauf si usage de la transparence). De plus, la gestion de la transparence était compliquée et les jeux devaient ruser en utilisation des solutions logicielles assez complexes. Niveau implémentation matérielle, une carte graphique en rendu direct demande juste trois circuits. Le premier est un circuit de calcul géométrique, qui rend la scène 3D. Le tri des quads est souvent réalisé par le processeur principal, et non pas par un circuit séparé. Toutes les étapes au-delà de l'étape de rastérisation étaient prises en charge par un VDC amélioré, qui écrivait des sprites/textures directement dans le ''framebuffer''. {|class="wikitable" |- ! Géométrie | Processeurs dédiés programmé pour émuler le pipeline graphique |- ! Tri des quads du plus lointain au plus proche | Processeur principal (implémentation logicielle) |- ! Application des textures | ''Blitter'' amélioré, capable de faire tourner et de zoomer sur des ''sprites''. |} L'implémentation était très simple et réutilisait des composants déjà existants : des VDC 2D pour l'application des textures, des processeurs dédiés pour la géométrie. Les unités de calcul de la géométrie étaient généralement implémentées avec un ou plusieurs processeurs dédiés. Vu qu'on savait déjà effectuer le rendu géométrique en logiciel, pas besoin de créer un circuit sur mesure. Il suffisait de dédier un processeur spécialisé rien que pour les calculs géométriques et on lui faisait exécuter un code déjà bien connu à la base. En clair, ils utilisaient un code spécifique pour émuler un circuit fixe. C'était clairement la solution la plus adaptée pour l'époque. Les unités géométriques étaient des processeurs RISC, normalement utilisés dans l'embarqué ou sur des serveurs. Elles utilisaient parfois des DSP. Pour rappel, les DSP des processeurs de traitement de signal assez communs, pas spécialement dédiés aux rendu 3D, mais spécialisé dans le traitement de signal audio, vidéo et autre. Ils avaient un jeu d'instruction assez proche de celui des cartes graphiques actuelles, et supportaient de nombreuses instructions utiles pour le rendu 3D. Le rendu direct a été utilisé dans la période de transition entre rendu 2D et rendu 3D, car il était très adapté pour faire cette transition. Coupler un VDC à un processeur pour la géométrie était particulièrement simple à l'époque. Le rendu direct a été utilisé sur des bornes d'arcade dès les années 90. Outre les bornes d'arcade, quelques consoles de 5ème génération utilisaient le rendu direct, avec les mêmes solutions matérielles. La géométrie était calculée sur plusieurs processeurs dédiés. Le reste du pipeline était géré par un VDC 2D qui implémentait le placage de textures. Deux consoles étaient dans ce cas : la 3DO, et la Sega Saturn. Le rendu direct est aujourd'hui abandonné. ===Le placage de textures inverse=== Le rendu précédent, le rendu direct, permet d'appliquer des textures directement dans le ''framebuffer''. Mais comme dit plus haut, il existe une seconde technique pour plaquer des textures, appelé le '''placage de texture inverse''', aussi appelé l'''UV Mapping''. Elle associe une texture complète pour un modèle 3D,contrairement au placage de tecture direct qui associe une texture par ''quad''/triangle. L'idée est que l'on attribue un texel à chaque sommet. Plus précisémment, chaque sommet est associé à des '''coordonnées de texture''', qui précisent quelle texture appliquer, mais aussi où se situe le texel à appliquer dans la texture. Par exemple, la coordonnée de texture peut dire : je veux le pixel qui est à ligne 5, colonne 27 dans cette texture. La correspondance entre texture et géométrie est réalisée lorsque les créateurs de jeu vidéo conçoivent le modèle de l'objet. [[File:Texture Mapping example.png|centre|vignette|upright=2|Exemple de placage de texture.]] Dans les faits, on n'utilise pas de coordonnées entières de ce type, mais deux nombres flottants compris entre 0 et 1. La coordonnée 0,0 correspond au texel en bas à gauche, celui de coordonnée 1,1 est tout en haut à droite. L'avantage est que ces coordonnées sont indépendantes de la résolution de la texture, ce qui aura des avantages pour certaines techniques de rendu, comme le ''mip-mapping''. Les deux coordonnées de texture sont notées u,v avec DirectX, ou encore s,t dans le cas général : u est la coordonnée horizontale, v la verticale. [[File:UVMapping.png|centre|vignette|upright=2|UV Mapping]] Avec le placage de texture inverse, la rastérisation se fait grosso-modo en trois étapes : la rastérisation proprement dite, le placage de textures, et les opérations finales qui écrivent un pixel dans le ''framebuffer''. Au niveau du matériel, ainsi que dans la plupart des API 3D, les trois étapes sont réalisées par des circuits séparés. [[File:01 3D-Rasterung-a.svg|vignette|Illustration du principe de la rasterization. La surface correspondant à l'écran est subdivisée en pixels carrés, de coordonnées x et y. La caméra est placée au point e. Pour chaque pixel, on trace une droite qui part de la caméra et qui passe par le pixel considéré. L'intersection entre une surface et cette droite se fait en un point, appartenant à un triangle.]] Lors de la rasterisation, chaque triangle se voit attribuer un ou plusieurs pixels à l'écran. Pour bien comprendre, imaginez une ligne droite qui part de caméra et qui passe par un pixel sur le plan de l'écran. Cette ligne intersecte 0, 1 ou plusieurs objets dans la scène 3D. Les triangles situés ces intersections entre cette ligne et les objets rencontrés seront associés au pixel correspondant. L'étape de rastérisation prend en entrée un triangle et renvoie la coordonnée x,y du pixel associé. Il s'agit là d'une simplification, car un triangle tend à occuper plusieurs pixels sur l'écran. L'étape de rastérisation fournit la liste de tous les pixels occupés par un triangle, et les traite un par un. Quand un triangle est rastérisé, le rasteriseur détermine la coordonnée x,y du premier pixel, applique une texture dessus, puis passe au suivant, et rebelote jusqu'à ce que tous les pixels occupés par le triangles aient été traités. L'implémentation matérielle du placage de texture inverse est beaucoup plus complexe que pour les autres techniques. Pour être franc, nous allons passer le reste du cours à parler de l'implémentation matérielle du placage de texture inverse, ce qui prendra plus d'une dizaine de chapitres. ==La transparence, les fragments et les ROPs== Dans ce qui suit, nous allons parler uniquement de la rastérisation avec placage de textures inverse. Les autres formes de rastérisation ne seront pas abordées. La raison est que tous les GPUs modernes utilisent cette forme de rastérisation, les exceptions étant rares. De même, ils utilisent un tampon de profondeur, pour l'élimination des surfaces cachées. La rastérisation effectue donc des calculs géométriques, suivis d'une étape de rastérisation, puis de placage des textures. Ces trois étapes sont réalisées par une unité géométrique, une unité de rastérisation, et un circuit de placage de textures. Du moins sur le principe, car les cartes graphiques modernes ont fortement optimisé l'implémentation et n'ont pas hésité à fusionner certains circuits. Mais nous verrons cela en temps voulu, nous n'allons pas résumer plusieurs décennies d'innovation technologique en quelques paragraphes. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Placage de textures |} Mais où mettre le tampon de profondeur ? Intuitivement, on se dit qu'il vaut mieux faire l'élimination des surfaces cachées le plus tôt possible, dès que la coordonnée de profondeur est connue. Et elle est connu à l'étape de rastérisation, une fois les sommets transformés. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Tampon de profondeur | Placage de textures |} En réalité, la profondeur des fragments est gérée par un circuit appelé le '''''Raster Operations Pipeline''''' (ROP), situé à la toute fin du pipeline graphique. Dans ce qui suit, nous utiliserons l'abréviation ROP pour simplifier les explications. Le ROP effectue quelques traitements sur les fragments, avant d'enregistrer l'image finale dans la mémoire vidéo. Il est placé à la fin du pipeline pour gérer correctement la transparence. Et nous allons voir pourquoi la transparence est gérée à la fin du pipeline. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Placage de textures | ''Raster Operations Pipeline'' |} ===Le mélange ''alpha''=== La transparence se manifeste quand plusieurs objets sont l'un derrière l'autre. Histoire de simplifier les explications, nous allons d'abord voir le cas où un objet semi-transparent est devant un objet opaque. La couleur perçue est alors un mélange de la couleur de l'objet opaque et celle de l'objet semi-transparent. Le mélange dépend d'à quel point l'objet semi-transparent est transparent. Avec un objet parfaitement transparent, seul l'objet opaque est visible. Avec un objet à moitié transparent, la couleur finale sera pour moitié celle de l'objet opaque, pour moitié celle de l'objet semi-transparent. Et c'est pareil pour les cas intermédiaires entre un objet totalement transparent et un objet totalement opaque. La transparence d'un objet/pixel est définie par un nombre, appelé la '''composante ''alpha'''''. Plus la composante alpha est élevée, plus le pixel est opaque. Elle vaut 0 pour un objet opaque et 1 pour un objet transparent. Elle est ajoutée aux composantes RGB, ce qui fait que tout fragment contient une "couleur de transparence" en plus des couleurs RGB. Elle agit comme un coefficient qui dit comment mélanger la couleur d'un objet transparent et d'un objet opaque. Le calcul de la transparence est une moyenne pondérée par la composante alpha. On parle alors d''''''alpha blending'''''. : <math>\text{Couleur finale} = \alpha \times \text{Couleur de l'objet transparent} + (1 - \alpha) \times \text{Couleur de l'objet opaque}</math> [[File:Texture splatting.png|centre|vignette|upright=2.0|Calcul de transparence. La première ligne montre le produit pour l'objet transparent, la seconde ligne est celle de l'objet opaque. La troisième ligne est celle de l'addition finale.]] Maintenant, qu'en est-il du cas où plusieurs objets sont superposés ? Si vous tracez une demi-droite dont l'origine est la caméra et qui passe par le pixel, il arrive qu'elle intersecte la géométrie en plusieurs points, un point par objet sur la ligne du regarde. Sans transparence, l'objet le plus proche cache tous les autres et c'est donc lui qui décide de la couleur du pixel. Mais avec un objet transparent, la couleur finale est un mélange de la couleur de plusieurs points d'intersection. Il faut donc calculer un pseudo-pixel pour chaque point d'intersection, auquel on donne le nom de '''fragment'''. Un fragment possède une position à l'écran, une coordonnée de profondeur, une couleur, ainsi que quelques autres informations potentiellement utiles. Les fragments attribués à un même pixel, qui sont à la même position sur l'écran, sont combinés pour obtenir la couleur finale de ce pixel. Il est possible d'utiliser le mélange ''alpha'' pour cela. Il suffit de faire le mélange ''alpha'' entre le fragment qui vient d'être calculé, et le pixel dans le ''framebuffer''. Pour cela, le fragment a une composante ''alpha'', qui est ajouté aux trois couleurs RGB. Le pixel déjà dans le ''framebuffer'' est un résultat temporaire, né du mélange ''alpha'' de tous les fragments précédents. Un défaut de cette méthode est qu'elle fonctionne assez mal avec un tampon de profondeur. Si le tampon de profondeur est activé, le mélange ''alpha'' ne fonctionne que si les objets sont rendus du plus lointain au plus proche. Et procéder dans cet ordre a un défaut : on dessine des objets dans le ''framebuffer'', pour qu'ensuite les objets devant écrasent ce qui a déjà été dessiné. Un même pixel peut donc être dessiné plusieurs fois, dont une seule sera pertinente. Et ces écritures utilisent de la bande passante mémoire, qui est une ressource précieuse sur un GPU moderne. Il s'agit d'un phénomène appelé '''''overdraw''''', ou sur-dessinage en français. Quelques optimisations permettent d'éliminer l'''overdraw'' en rendant les objets du plus proche au plus lointain, d'autres permettent de dessiner des objets dans un ordre arbitraire, mais nous ne pouvons pas en parler ici. Beaucoup de moteurs 3D rendent séparément les objets opaques et transparents. Une première passe rend les objets opaques, puis les objets transparents sont rendus dans une seconde passe. Les objets opaques sont rendus dans le désordre, ce qui fait qu'on n'a pas à les trier, alors que les objets transparents doivent être triés selon leur distance. un autre avantage est que le mélange ''alpha'' est désactivé lors de la première passe, alors que c'est la mise à jour du tampon de profondeur qui est désactivé lors de la seconde passe, ce qui augmente un peu les performances dans les deux cas. ===Le test ''alpha''=== Le test ''alpha'' est une technique qui permet d'annuler le rendu d'un fragment en fonction de sa transparence. Si la composante alpha est en-dessous ou au-dessus d'un seuil, le fragment est simplement abandonné. Le seuil en question est configurable, de même que la comparaison utilisée : on peut éliminer le fragment si sa transparence est au-dessus d'un certain seuil, en-dessous, égal, différent, etc. Il s'agit d'une optimisation qui est utile dans certains scénarios spécifiques. Par exemple, si l'objet a une transparence très élevée, du genre 95%, autant le compter comme complétement transparent, afin d'éviter des opérations de mélange ''alpha''. En effet, les opérations de mélange ''alpha'' sont très lentes, car elles demandent de faire des opérations de lecture-écriture en mémoire vidéo : on lit un pixel dans le ''framebuffer'', on applique le mélange ''alpha'' et on écrit le résultat en mémoire vidéo. L'''alpha test'' permet donc de gagner en performance au prix d'une baisse de la qualité d'image. Il y a cependant des cas où l'usage du test ''alpha'' est primordial, au-delà d'une question de performances. Un exemple classique est celui du rendu du feuillage dans un jeu 3D. Un feuillage est composé en assemblant plusieurs images de feuilles. Chaque feuille est un carré sur lequel on place une texture de feuille, qui est opaque pour la partie verte des feuilles, transparente pour le reste. Les carrés ne sont cependant pas superposés, mais s'intersectent fortement, ce qui fait que le mélange ''alpha'' ne donne pas de bons résultats. L'usage du test ''alpha'' permet d'obtenir un rendu correct. Pour d'informations via ce lien : * [https://bgolus.medium.com/anti-aliased-alpha-test-the-esoteric-alpha-to-coverage-8b177335ae4f Anti-aliased Alpha Test: The Esoteric Alpha To Coverage]. ===Les effets de brouillard=== Les '''effets de brouillard''' sont nécessaires dans certains jeux vidéo pour l'ambiance (pensez à des jeux d'horreur comme Silent Hill), mais ils ont surtout été utilisés pour économiser des calculs. L'idée est de ne pas calculer les graphismes au-delà d'une certaine distance, sans que cela se voie. Le ''view frustum'' utilise alors un plan limite, au-delà duquel on ne voit pas les objets. Mais ce plan limite donne une cassure inesthétique dans le rendu. Pour masquer cette cassure, les programmeurs ajoutaient un effet de brouillard. Les objets au-delà du plan limite étaient totalement dans le brouillard, puis ce brouillard se réduisait progressivement en se rapprochant de la caméra, avant de s'annuler à partir d'une certaine distance. Pour calculer le brouillard, on effectue un mélange ''alpha'' entre la couleur du pixel et une ''couleur de brouillard''. La différence est que l'on n'utilise pas la transparence pour faire le mélange, mais un '''coefficient de brouillard''', noté <math>\text{fog}(z)</math>. : <math>\text{Couleur finale} = \text{fog}(z) \times \text{Couleur de brouillard} + [ 1 - \text{fog}(z) ] \times \text{Couleur du pixel}</math> Le coefficient de brouillard dépend de la coordonnée de profondeur, de la distance du pixel par rapport à la caméra. Le brouillard démarre à une distance <math>z_{fog-start}</math>, et masque totalement les objets à partir d'une distance <math>z_{fog-end}</math>. Entre les deux, le coefficient de brouillard dépend de la distance. OpenGL autorise trois formules de calcul suivantes : : <math>\text{fog}(z) = \frac{z_{fog-end} - z}{z_{fog-end} - z_{fog-start}}</math> : <math>\text{fog}(z) = e^{- k \times z}</math> : <math>\text{fog}(z) = e^{- (k \times z)^2}</math> ==L'éclairage d'une scène 3D== L'éclairage d'une scène 3D calcule les ombres, mais aussi la luminosité de chaque pixel, ainsi que bien d'autres effets graphiques. Les algorithmes d'éclairage ont longtemps été implémentés directement en matériel, les cartes graphiques géraient l'éclairage dans des circuits spécialisés. Aussi, il est important de voir ces algorithmes d'éclairage. Il est possible d'implémenter l'éclairage à deux endroits différents du pipeline : juste avant la rastérisation, et après la rastérisation. ===Les sources de lumière et les couleurs associées=== L'éclairage d'une scène 3D provient de sources de lumières, comme des lampes, des torches, le soleil, etc. Il existe de nombreux types de sources de lumière, et nous n'allons parler que des principales. Elles sont au nombre de quatre et elles sont illustrées ci-dessous. [[File:3udUJ.gif|centre|vignette|upright=2|Types de sources de lumière.]] [[File:Graphics lightmodel directional.png|vignette|upright=1.0|Source de lumière directionnelle.]] Les '''sources directionnelles''' servent à modéliser des sources de lumière très éloignées, comme le soleil ou la lune. Elles sont simplement définies par un vecteur qui indique la direction de la lumière, rien de plus. Les '''sources ponctuelles''' sont des points, qui émettent de la lumière dans toutes les directions. Elles sont définies par une position, et une intensité lumineuse, éventuellement la couleur de la lumière émise. Il existe deyux types de sources de lumière ponctuelles. * Le premières émettent de manière égale dans toutes les directions. Elles sont appelées des ''point light'' dans le schéma du dessus. * Les secondes émettent de la lumière dans une '''direction privilégiée'''. L'exemple le plus parlant est celui d'une lampe-torche : elle émet de la lumière "tout droit", dans la direction où la lampe est orientée. Elles sont appelées des ''sport light'' dans le schéma du dessus. La direction privilégiée est un vecteur, notée v dans le schéma du dessous. [[File:Graphics lightmodel ambient.png|vignette|upright=1.0|Lumière ambiante.]] En théorie, la lumière rebondit sur les surfaces et a tendance à se disperser un peu partout à force de rebondir. C'est ce qui explique qu'on arrive à voir à l'intérieur d'une pièce si une fenêtre est ouverte. Il en résulte un certain '''éclairage ambiant''', qui est assez difficile à représenter dans un moteur de rendu 3D. Auparavant, l'éclairage ambiant était simulé par une lumière égale en tout point de la scène 3D, appelée simplement la '''lumière ambiante'''. Précisément, on suppose que la lumière ambiante en un point vient de toutes les directions et a une intensité constante, identique dans toutes les directions. Le tout est illustré ci-contre. C'est assez irréaliste, mais ça donne une bonne approximation de la lumière ambiante. ===La lumière incidente : le terme géométrique=== Pour simplifier, nous allons supposer que l'éclairage est calculé pour chaque sommet, pas par triangle. C'est de loin le cas le plus courant, aussi ce n'est pas une simplification abusive. La lumière qui arrive sur un sommet est appelée la '''lumière incidente'''. La couleur d'un sommet dépend de deux choses : la lumière incidente directe, comment il réfléchit cette lumière. Mathématiquement, il est possible de résumer cela avec le produit de deux termes : l'intensité de la lumière incidente, une fonction qui indique comment la surface réfléchit la lumière incidente. La fonction en question est appelée la '''réflectivité bidirectionnelle'''. Le terme anglais est ''bidirectional reflectance distribution function'', abrévié en BRDF, et nous utiliserons cette abréviation dans ce qui suit. : <math>\text{Couleur finale} = \text{Lumière incidente} \times BRDF(...)</math> La lumière incidente vient soit directement des sources de lumière, soit de la lumière qui a rebondit sur d'autres objets proches. La première est appelée la lumière directe, celle qui vient des rebonds s'appelle la lumière indirecte. Pour simplifier, la lumière indirecte est gérée par la lumière ambiante, nous passons sous silence les techniques d'illumination globale. En clair : nous allons nous limiter au cas où la lumière incidente vient directement d'une source de lumière, pas d'un rebond. Intuitivement, la lumière incidente est simplement égale à l'intensité de la source de lumière. Sauf que ce n'est qu'une approximation, et une assez mauvaise. En réalité, l'approximation est bonne si la lumière arrive proche de la verticale, mais elle est d'autant plus mauvaise que la lumière arrive penchée, voire rasante. La raison : la lumière incidente sera étalée sur une surface plus grande, si elle arrive penchée. Si vous vous souvenez de vos cours de collège, c'est le même principe qui explique les saisons. La lumière du soleil est proche de la verticale en été, mais est de plus en plus penché quand on s'avance vers l'Hiver. La lumière solaire est donc étalée sur une surface plus grande, ce qui fait qu'un point de la surface recevra moins de lumière, celle-ci étant diluée, étalée. [[File:Radiación solar.png|centre|vignette|upright=2|Exemple avec la lumière solaire.]] [[File:Angle of incidence.svg|vignette|upright=1|Angle d'incidence.]] En clair, tout dépend de l''''angle d'incidence''' de la lumière. Reste à voir comment calculer cet angle. La lumière incidente est définie par un vecteur, qui part de la source de lumière et atterrit sur le sommet considéré. Imaginez simplement que ce vecteur suit un rayon lumineux provenant de la source de lumière. Le vecteur pour la lumière incidente sera noté L. L'angle d'incidence est l'angle que fait ce vecteur avec la verticale de la surface, au niveau du sommet considéré. [[File:Graphics lightmodel ptsource.png|vignette|Normale de la surface.]] Pour cela, les calculs d'éclairage ont besoin de connaitre la verticale d'un sommet. Un sommet est donc associé à un vecteur, appelé la '''normale''', qui indique la verticale en ce point. Deux sommets différents peuvent avoir deux normales différentes, même s'ils sont proches. Elles sont d'autant plus différentes que la surface est rugueuse, non-lisse. La normale est prédéterminée lors de la création du modèle 3D, il n'y a pas besoin de le calculer. Par contre, elle est modifiée lors de l'étape de transformation, quand on place le modèle 3D dans la scène 3D. Les deux autres vecteurs sont à calculer à chaque image, car ils changent quand on bouge le sommet. La lumière qui arrive sur la surface dépend de l'angle entre la normale et le vecteur L. Précisément, elle dépend du cosinus de cet angle. En multipliant ce cosinus avec l'intensité de la lumière, on a la lumière arrivante. La couleur finale d'un pixel est donc : : <math>\text{Couleur finale} = I \times \cos{(N, L)} \times BRDF(...)</math> Le terme <math>I \times \cos{N, L}</math> ne dépend pas de la surface considérée. Juste de la position de la source de lumière, de la position du sommet et de son orientation par rapport à la lumière. Aussi, il est parfois appelé le '''terme géométrique''', en opposition aux propriétés de la surface. Les propriétés de la surface sont définies par un '''''material''''', qui indique comment il réfléchit la lumière, ainsi que sa texture. ===Le produit scalaire de deux vecteurs=== Calculer le terme géométrique demande de calculer le cosinus d'un angle. Et il n'est pas le seul : les autres calculs d'éclairage que nous allons voir demandent de calculer des cosinus. Or, les calculs trigonométriques sont très gourmands pour le GPU. Pour éviter le calcul d'un cosinus, les GPU utilisent une opération mathématique appelée le ''produit scalaire''. Le produit scalaire agit sur deux vecteurs, que l'on notera A et B. Un produit scalaire prend : la longueur des deux vecteurs, et l'angle entre les deux vecteurs noté <math>\omega</math>. Le produit scalaire est équivalent à la formule suivante : : <math>\text{Produit scalaire de deux vecteurs A et B} = \vec{A} \cdot \vec{B} = A \times B \times \cos{(\omega)}</math>, avec A et B la longueur des deux vecteurs A et B. L'avantage est que le produit scalaire se calcule simplement avec des additions, soustractions et multiplications, des opérations que les cartes graphiques savent faire très facilement. Le produit scalaire de deux vecteurs de coordonnées x,y,z est le suivant : : <math>\vec{A} \cdot \vec{B} = x_A \times x_B + y_A \times y_B + z_A \times z_B</math> En clair, on multiplie les coordonnées identiques, et on additionne les résultats. Rien de compliqué. Un avantage est que tous les vecteurs vus précédemment sont normalisés, à savoir qu'ils ont une longueur qui vaut 1. Ainsi, le calcul du produit scalaire devient équivalent au calcul du produit scalaire. ===La réflexion de la lumière sur la surface=== [[File:Ray Diagram 2.svg|vignette|Reflection de la lumière sur une surface parfaitement lisse.]] Maintenant que nous venons de voir le terme géométrique, voyons le BRDF, qui définit comment la surface de l'objet 3D réfléchit la lumière. Vos cours de collège vous ont sans doute appris que la lumière est réfléchie avec le même angle d'arrivée. L'angle d'incidence et l'angle de réflexion sont égaux, comme illustré ci-contre. On parle alors de '''réflexion parfaite'''. Mais cela ne vaut que pour une surface parfaitement lisse, comme un miroir parfait. Dans la réalité, une surface a tendance à renvoyer des rayons dans toutes les directions. La raison est qu'une surface réelle est rugueuse, avec de petites aspérités et des micro-reliefs, qui renvoient la lumière dans des directions "aléatoires". La lumière « rebondit » sur la surface de l'objet et une partie s'éparpille dans un peu toutes les directions. On parle alors de '''réflexion diffuse'''. {| |- |[[File:Dioptre reflexion diffuse speculaire refraction.svg|vignette|upright=1.4|Différence entre réflexion diffuse et spéculaire.]] |[[File:Diffuse reflection.svg|vignette|upright=1|Réflexion diffuse.]] |} Maintenant, imaginons que la surface n'ait qu'une réflexion diffuse, pas d'autres formes de réflexion. Et imaginons aussi que cette réflexion diffuse soit parfaite, à savoir que la lumière réfléchie soit renvoyée à l'identique dans toutes les directions, sans aucune direction privilégiée. On a alors le ''material'' le plus simple qui soit, appelé un '''''diffuse material'''''. Vu que la lumière est réfléchie à l'identique dans toutes les directions, elle sera identique peu importe où on place la caméra. La lumière finale ne dépend donc que des propriété de la surface, que de sa couleur. En clair, il suffit de donner une '''couleur diffuse''' à chaque sommet. La couleur diffuse est simplement multipliée par le terme géométrique, pour obtenir la lumière réfléchie finale. Rien de plus, rien de moins. Cela donne l'équation suivante, avec les termes suivants : * L est le vecteur pour la lumière incidente ; * N est la normale du sommet ; * I est l'intensité de la source de lumière ; * <math>C_d</math> est la couleur diffuse. : <math>\text{Illumination diffuse} = C_d \times \left[ I \times (\vec{N} \cdot \vec{L}) \right]</math> Rajoutons maintenant l'effet de la lumière ambiante à un ''material'' de ce genre. Pour rappel, la lumière ambiante vient de toutes les directions à part égale, ce qui fait que son angle d'incidence n'a donc pas d'effet. L'intensité de la lumière ambiante est déterminée lors de la création de la scène 3D, c'est une constante qui n'a pas à être calculée. Pour obtenir l'effet de la lumière ambiante sur un objet, il suffit de multiplier sa couleur diffuse par l'intensité de la lumière ambiante. Cependant, de nombreux moteurs de jeux ajoutent une '''couleur ambiante''', différente de la couleur diffuse. : <math>\text{Illumination ambiante} = C_a \times I_a</math> avec <math>C_a</math> la couleur ambiante du point de surface et <math>I_a</math> l'intensité de la lumière ambiante. En plus de la réflexion diffuse parfaite, de nombreux matériaux ajoutent une '''réflexion spéculaire''', qui n'est pas exactement la réflexion parfaite, en est très proche. Les rayons réfléchis sont très proches de la direction de réflexion parfaite, et s'atténuent très vite en s'en éloignant. Le résultat ressemble à une sorte de petit "point blanc", très lumineux, orienté vers la source de lumière, appelé le '''''specular highlight'''''. La réflexion diffuse est prédominante pour les matériaux rugueux, alors que la réflexion spéculaire est dominante sur les matériaux métalliques ou très lisses. [[File:Phong components version 4.png|centre|vignette|upright=3.0|Couleurs utilisées dans l'algorithme de Phong.]] [[File:Phong Vectors.svg|vignette|Vecteurs utilisés dans l'algorithme de Phong (et dans le calcul de l'éclairage, de manière générale).]] Pour calculer la réflexion spéculaire, il faut d'abord connaitre le vecteur pour la réflexion parfaite, que nous noterons R dans ce qui suit. Le vecteur R peut se calculer avec la formule ci-dessous : : <math>\vec{R} = 2 (\vec{L} \cdot \vec{N}) \times \vec{N} - \vec{L} </math> La réflexion spéculaire dépend de l'angle entre la direction du regard et la normale : plus celui-ci est proche de l'angle de réflexion parfaite, plus la réflexion spéculaire sera intense. Le vecteur pour la direction du regard sera noté V, pour vue ou vision. La réflexion spéculaire est une fonction qui dépend de l'angle entre les vecteurs R et V. Le calcul de la réflexion spéculaire utilise une '''couleur spéculaire''', qui est l'équivalent de la couleur diffuse pour la réflexion spéculaire. : <math>\text{BRDF spéculaire} = C_s \times f(\vec{R} \cdot \vec{V}) </math> La fonction varie grandement d'un modèle de calcul spéculaire à l'autre. Aussi, je ne rentre pas dans le détail. L'essentiel est que vous compreniez que le calcul de l'éclairage utilise de nombreux calculs géométriques, réalisés avec des produits scalaires. Les calculs géométriques utilisent la couleur d'un sommet, la normale du sommet, et le vecteur de la lumière incidente. Les autres informations sont calculées à l'exécution. ===Les algorithmes d'éclairage basiques : par triangle, par sommet et par pixel=== Dans tout ce qui a été dit précédemment, l'éclairage est calculé pour chaque sommet. Il attribue une illumination/couleur à chaque sommet de la scène 3D, ce qui fait qu'on parle d''''éclairage par sommet''', ou ''vertex lighting''. Il est assez rudimentaire et donne un éclairage très brut, mais il peut être réalisé avant l'étape de rastérisation. Mais une fois qu'on a obtenu la couleur des sommets, reste à colorier les triangles. Pour obtenir la couleur finale d'un pixel, l'éclairage par sommet fait une moyenne de la couleur de chaque sommet. La moyenne est une moyenne pondérée, qui tient compte de la distance du sommet avec le pixel. Plus le pixel est loin d'un sommet, plus son coefficient est petit. Typiquement, le coefficient varie entre 0 et 1 : de 1 si le pixel est sur le sommet, à 0 si le pixel est sur un des sommets adjacents. La moyenne effectuée est généralement une interpolation bilinéaire, qui est prise en charge par l'étape de rastérisation. L'éclairage par sommet a eu son heure de gloire, mais il est maintenant remplacé par l''''éclairage par pixel''' (''per-pixel lighting''), qui calcule l'éclairage pixel par pixel. En clair, l’éclairage est finalisé après l'étape de rastérisation, il ne se fait pas qu'au niveau de la géométrie. Il existe plusieurs types d'éclairage par pixel, mais on peut les classer en deux grands types : l'éclairage de Phong et le ''bump/normal mapping''. L''''éclairage de Phong''' calcule l'éclairage pixel par pixel. Avec cet algorithme, la géométrie n'est pas éclairée : les couleurs des sommets ne sont pas calculées. A la place, les normales sont envoyées à l'étape de rastérisation, qui effectue une opération d'interpolation, qui renvoie une normale pour chaque pixel. Les calculs d'éclairage utilisent alors ces normales pour faire les calculs d'éclairage pour chaque pixel. La technique du '''''normal mapping''''' est assez simple à expliquer, sans compter que plusieurs cartes graphiques l'ont implémentée directement dans leurs circuits. Là où l'éclairage de Phong interpole les normales pour chaque pixel, le ''normal-mapping'' précalcule les normales d'une surface dans une texture, appelée la ''normal-map''. Lors du rendu 3D, la carte graphique lit les normales adéquates directement depuis cette texture, puis fait les calculs d'éclairage avec. Le ''normal mapping'' permettent d'ajouter du relief et des détails sur des surfaces planes en jouant sur l'éclairage. Il permet de simplifier grandement la géométrie rendue, tout en utilisant l'éclairage pour compenser. [[File:WallSimpleAndNormalMapping.png|centre|vignette|upright=2|Différence sans et avec ''normal-mapping''.]] L'éclairage par pixel donne une qualité d'image supérieure à l'éclairage par sommet, mais il est aussi plus gourmand. Mais il est devenu la norme sur les jeux vidéos actuels. La différence entre l'éclairage par pixel et par sommet se voit assez facilement à l'écran. {| |- |[[File:Per face lighting.png|vignette|upright=1|Eclairage par triangle.]] |[[File:Per vertex lighting.png|vignette|upright=1|Eclairage par sommet.]] |[[File:Per fragment lighting.png|vignette|upright=1|Eclairage de Phong.]] |- |[[File:Per face lighting example.png|vignette|upright=1|Eclairage par triangle.]] |[[File:Per vertex lighting example.png|vignette|upright=1|Eclairage par sommet.]] |[[File:Per fragment lighting example.png|vignette|upright=1|Eclairage de Phong.]] |} ===Les ''shaders'' : des programmes exécutés sur le GPU=== Maintenant que nous venons de voir les algorithmes d'éclairages, il est temps de voir comment les réaliser sur une carte graphique. Nous venons de voir qu'il y a une différence entre l'éclairage par pixel et par sommet. Intuitivement, l'éclairage par sommet devrait se faire avec les calculs géométriques, alors que l'éclairage par pixel devrait se faire après avoir appliqué les textures. Les toutes premières cartes graphiques ne géraient ni l'éclairage par sommet, ni l'éclairage par pixel. Elles laissaient les calculs géométriques au CPU. Par la suite, la Geforce 256 a intégré '''circuit de ''Transform & Lightning''''', qui s'occupait de tous les calculs géométriques, éclairage par sommet inclus (d'où le L de T&L). Elle gérait alors l'éclairage par sommet, mais un algorithme particulier, qui n'était pas très flexible. Il ne gérait que des ''material'' bien précis (des ''Phong materials''), rien de plus. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Unité de T&L : géométrie | Rastérisation | Placage de textures | ''Raster Operations Pipeline'' |} L'amélioration suivante est venue sur la Geforce 3 : l'unité de T&L est devenue programmable. Au vu le grand nombre d'algorithmes d'éclairages possibles et le grand nombre de ''materials'' possibles, c'était la seule voie possibles. Les programmeurs pouvaient programmer leurs propres algorithmes d'éclairage par sommet, même s'ils devaient aussi programmer les étapes de transformation et de projection. Mais nous détaillerons cela dans un chapitre dédié sur l'historique des GPUs. Ce qui est important est que la Geforce 3 a introduit une fonctionnalité absolument cruciale pour le rendu 3D moderne : les '''''shaders'''''. Il s'agit de programmes informatiques exécutés par la carte graphique, qui servaient initialement à coder des algorithmes d'éclairage. D'où leur nom : ''shader'' pour ''shading'' (éclairage en anglais). Cependant, l'usage modernes des shaders dépasse le cadre des algorithmes d'éclairage. L'avantage est que cela simplifie grandement l'implémentation des algorithmes d'éclairage. Pas besoin de les intégrer dans la carte graphique pour les utiliser, pas besoin d'un circuit distinct pour chaque algorithme. Sans shaders, si la carte graphique ne gère pas un algorithme d'éclairage, on ne peut pas l'utiliser. A la rigueur, il est parfois possible de l'émuler avec des contournements logiciels, mais au prix de performances souvent désastreuses. Avec des shaders, il est possible de programmer l'algorithme d'éclairage de notre choix, pour l'exécuter sur la carte graphique, avec des performances plus que convenables. [[File:Implémentation de l'éclairage sur les cartes graphiques.png|vignette|Implémentation de l'éclairage sur les cartes graphiques]] Il existe plusieurs types de shaders, mais les deux principaux sont les '''''vertex shaders''''' et les '''''pixel shaders'''''. Les pixels shaders s'occupent de l'éclairage par pixel, leur nom est assez parlent. Les vertex shaders s'occupent de l'éclairage par sommet, mais aussi des étapes de transformation/projection. Je parle bien des trois étapes de transformation vues plus haut, qui effectuent des calculs de transformation de coordonnées avec des matrices. La raison à cela est que les calculs de transformation ressemblent beaucoup aux calculs d'éclairage par sommet. Ils impliquent tous deux des calculs vectoriels, comme des produits scalaires et des produits vectoriels, qui agissent sur des sommets/triangles. Si la carte graphique incorpore un processeur de shader capable de faire de tels calculs, alors il peut servir pour les deux. Pour implémenter les shaders, il a fallu ajouter des processeurs à la carte graphique. Les processeurs en question exécutent les shaders, ils peuvent lire ou écrire dans des textures, mais ne font rien d'autres. Les ''vertex shaders'' font tout ce qui a trait à la géométrie, ils remplacent l'unité de T&L. Les pixels shaders sont entre la rastérisation et les ROPs, ils sont très liés à l'unité de texture. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | rowspan="2" class="f_rouge" | ''Vertex shader'' | rowspan="2" | Rastérisation | Placage de textures | rowspan="2" |''Raster Operations Pipeline'' |- | class="f_rouge" | ''Pixel shader'' |} <noinclude> {{NavChapitre | book=Les cartes graphiques | prev=Les cartes d'affichage des anciens PC | prevText=Les cartes d'affichage des anciens PC | next=Avant les GPUs : les cartes accélératrices 3D | nextText=Avant les GPUs : les cartes accélératrices 3D }}{{autocat}} </noinclude> hntulnyo8h6smr5t2jn036u93h3x07p 765149 765148 2026-04-26T20:13:04Z Mewtow 31375 /* Les algorithmes d'éclairage basiques : par triangle, par sommet et par pixel */ 765149 wikitext text/x-wiki Le premier jeu à utiliser de la "vraie 3D" texturée fut le jeu Quake, premier du nom. Et depuis sa sortie, la grande majorité des jeux vidéo utilisent de la 3D, même s'il existe encore quelques jeux en 2D. Face à la prolifération des jeux vidéo en 3D, les fabricants de cartes graphiques ont inventé les cartes accélératrices 3D, des cartes vidéo capables d'accélérer le rendu en 3D. Dans ce chapitre, nous allons voir comment elles fonctionnent et comment elles ont évolué dans le temps. Pour comprendre comment celles-ci fonctionnent, il faut faire quelques rapides rappels sur les bases du rendu 3D. ==Les bases du rendu 3D== Une '''scène 3D''' est composée d'un espace en trois dimensions, dans laquelle le moteur d’un jeu vidéo place des objets et les fait bouger. Cette scène est, en première approche, un simple parallélogramme. Un des coins de ce parallélogramme sert d’origine à un système de coordonnées : il est à la position (0, 0, 0), et les axes partent de ce point en suivant les arêtes. Les objets seront placés à des coordonnées bien précises dans ce parallélogramme. ===Les objets 3D et leur géométrie=== <noinclude>[[File:Dolphin triangle mesh.png|vignette|Illustration d'un dauphin, représenté avec des triangles.]]</noinclude> Dans la quasi-totalité des jeux vidéo actuels, les objets et la scène 3D sont modélisés par un assemblage de triangles collés les uns aux autres, ce qui porte le nom de '''maillage''', (''mesh'' en anglais). Il a été tenté dans le passé d'utiliser des quadrilatères (rendu dit en ''quad'') ou d'autres polygones, mais les contraintes techniques ont fait que ces solutions n'ont pas été retenues. [[File:CG WIKI.jpg|centre|vignette|upright=2|Exemple de modèle 3D.]] Les modèles 3D sont définis par leurs sommets, aussi appelés '''vertices''' dans le domaine du rendu 3D. Chaque sommet possède trois coordonnées, qui indiquent sa position dans la scène 3D : abscisse, ordonnée, profondeur. Les sommets sont regroupés en triangles, qui sont formés en combinant trois sommets entre eux. Les anciennes cartes graphiques géraient aussi d'autres formes géométriques, comme des points, des lignes, ou des quadrilatères. Les quadrilatères étaient appelés des ''quads'', et ce terme reviendra occasionnellement dans ce cours. De telles formes basiques, gérées nativement, sont appelées des '''primitives'''. La représentation exacte d'un objet est donc une liste plus ou moins structurée de sommets. La liste doit préciser les coordonnées de chaque sommet, ainsi que comment les relier pour former des triangles. Pour cela, l'objet est représenté par une structure qui contient la liste des sommets, mais aussi de quoi savoir quels sont les sommets reliés entre eux par un segment. Nous en dirons plus dans le chapitre sur le rendu de la géométrie. ===La caméra : le point de vue depuis l'écran=== Outre les objets proprement dit, on trouve une '''caméra''', qui représente les yeux du joueur. Cette caméra est définie au minimum par : * une position ; * par la direction du regard (un vecteur). A la caméra, il faut ajouter tout ce qui permet de déterminer le '''champ de vision'''. Le champ de vision contient tout ce qui est visible à l'écran. Et sa forme dépend de la perspective utilisée. Dans le cas le plus courant dans les jeux vidéos en 3D, il correspond à une '''pyramide de vision''' dont la pointe est la caméra, et dont les faces sont délimitées par les bords de l'écran. A l'intérieur de la pyramide, il y a un rectangle qui représente l'écran du joueur, appelé le '''''viewport'''''. <noinclude>[[File:ViewFrustum.jpg|centre|vignette|upright=2|Caméra.]]</noinclude> [[File:ViewFrustum.svg|vignette|upright=1|Volume délimité par la caméra (''view frustum'').]] La majorité des jeux vidéos ajoutent deux plans : * un ''near plane'' en-deça duquel les objets ne sont pas affichés. Il élimine du champ de vision les objets trop proches. * Un ''far plane'', un '''plan limite''' au-delà duquel on ne voit plus les objets. Il élimine les objets trop lointains. Avec ces deux plans, le champ de vision de la caméra est donc un volume en forme de pyramide tronquée, appelé le '''''view frustum'''''. Le tout est parfois appelée, bien que par abus de langage, la pyramide de vision. Avec d'autres perspectives moins utilisées, le ''view frustum'' est un pavé, mais nous n'en parlerons pas plus dans le cadre de ce cours car elles ne sont presque pas utilisés dans les jeux vidéos actuels. ===Les textures=== Tout objet à rendre en 3D est donc composé d'un assemblage de triangles, et ceux-ci sont éclairés et coloriés par divers algorithmes. Pour rajouter de la couleur, les objets sont recouverts par des '''textures''', des images qui servent de papier peint à un objet. Un objet géométrique est donc recouvert par une ou plusieurs textures qui permettent de le colorier ou de lui appliquer du relief. [[File:Texture+Mapping.jpg|centre|vignette|upright=2|Texture Mapping]] Notons que les textures sont des images comme les autres, codées pixel par pixel. Pour faire la différence entre les pixels de l'écran et les pixels d'une texture, on appelle ces derniers des '''texels'''. Ce terme est assez important, aussi profitez-en pour le mémoriser, nous le réutiliserons dans quelques chapitres. Un autre point lié au fait que les textures sont des images est leur compression, leur format. N'allez pas croire que les textures sont stockées dans un fichier .jpg, .png ou tout autre format de ce genre. Les textures utilisent des formats spécialisés, comme le DXTC1, le S3TC ou d'autres, plus adaptés à leur rôle de texture. Mais qu'il s'agisse d'images normales (.jpg, .png ou autres) ou de textures, toutes sont compressées. Les textures sont compressées pour prendre moins de mémoire. Songez que la compression de texture est terriblement efficace, souvent capable de diviser par 6 la mémoire occupée par une texture. S'en est au point où les textures restent compressées sur le disque dur, mais aussi dans la mémoire vidéo ! Nous en reparlerons dans le chapitre sur la mémoire d'une carte graphique. Plaquer une texture sur un objet peut se faire de deux manières, qui portent les noms de placage de texture inverse et direct. Le placage de texture direct a été utilisé au tout début de la 3D, sur des bornes d'arcade et les consoles de jeu 3DO, PS1, Sega Saturn. De nos jours, on utilise uniquement la technique de placage de texture inverse. Les deux seront décrites dans le détail plus bas. ===La différence entre rastérisation et lancer de rayons=== Les techniques de rendu 3D sont nombreuses, mais on peut les classer en deux grands types : le ''lancer de rayons'' et la ''rasterization''. Sans décrire les deux techniques, sachez cependant que le lancer de rayon n'est pas beaucoup utilisé pour les jeux vidéo. Il est surtout utilisé dans la production de films d'animation, d'effets spéciaux, ou d'autres rendu spéciaux. Dans les jeux vidéos, il est surtout utilisé pour quelques effets graphiques, la rasterization restant le mode de rendu principal. La raison principale est que le lancer de rayons demande beaucoup de puissance de calcul. Une autre raison est que créer des cartes accélératrices pour le lancer de rayons n'est pas simple. Il a existé des cartes accélératrices permettant d'accélérer le rendu en lancer de rayons, mais elles sont restées confidentielles. Les cartes graphiques modernes incorporent quelques circuits pour accélérer le lancer de rayons, mais ils restent d'un usage marginal et servent de compléments au rendu par rastérization. Un chapitre entier sera dédié aux cartes accélératrices de lancer de rayons et nous verrons pourquoi le lancer de rayons est difficile à implémenter avec des performances convenables, ce qui explique que les jeux vidéo utilisent la ''rasterization''. La rastérisation est structurée autour de trois étapes principales : * Une étape purement logicielle, effectuée par le processeur, où le moteur physique calcule la géométrie de la scène 3D. * Une étape de '''traitement de la géométrie''', qui gère tout ce qui a trait aux sommets et triangles. * Une étape de '''rastérisation''' qui détermine sur quels pixels de l'écran est affiché le triangle. * Une étape de '''traitement des pixels''', qui colorie les pixels et gère les textures. [[File:Graphics pipeline 2 en.svg|centre|vignette|upright=2.5|Pipeline graphique basique.]] Il existe plusieurs rendus différents et la rastérisation ne se fait pas de la même manière selon le rendu utilisé. Il existe des rendus sans textures, d'autres avec, d'autres avec éclairage, d'autres sans, etc. Par contre, l'étape de calcul de la géométrie est la même quel que soit le rendu ! Mieux : le calcul de la géométrie se fait de la même manière entre rastérisation et lancer de rayons, il est le même quelle que soit la technique de rendu 3D utilisée. Les trois étapes précédentes sont réalisées dans des circuits ou processeurs séparés, comme on le verra plus tard. Et cela permet d'utiliser la technique dite du '''pipeline'''. Concrètement, supposons que la carte graphique traite les données par paquets de triangles (en réalité, c'est des paquets de sommets, mais passons). L'étape de traitement de la géométrie peut travailler sur un paquet de triangle, pendant que le paquet précédent est dans l'étape de rastérisation, et que le paquet encore précédent est en train de traiter ses pixels. Cela permet de traiter trois paquets de triangles en même temps, mais à des états d'avancements différents. Mieux que cela : le traitement de la géométrie est lui-même composé d'une succession de sous-étapes, la rasterisation est elle-même découpée en plusieurs sous-étapes, et ainsi de suite. Le nombre d'étapes pour une carte graphique moderne dépasse la dizaine. La rastérisation calcule un rendu 3D avec une suite d'étapes consécutives qui doivent s'enchainer dans un ordre bien précis. L'ensemble de ces étapes est appelé le '''pipeline graphique''', qui sera détaillé dans ce qui suit. ==Le calcul de la géométrie== Le calcul de la géométrie regroupe plusieurs manipulations différentes. La principale demande juste de placer les modèles 3D dans la scène, de placer les objets dans le monde. Puis, il faut centrer la scène 3D sur la caméra. Les deux changements ont pour point commun de demander des changements de repères. Par changement de repères, on veut dire que l'on passe d'un système de coordonnées à un autre. En tout, il existe trois changements de repères distincts qui sont regroupés dans l''''étape de transformation''' : un premier qui place chaque objet 3D dans la scène 3D, un autre qui centre la scène du point de vue de la caméra, et un autre qui corrige la perspective. ===Les trois étapes de transformation=== La première étape place les objets 3D dans la scène 3D. Un modèle 3D est représentée par un ensemble de sommets, qui sont reliés pour former sa surface. Les données du modèle 3D indiquent, pour chaque sommet, sa position par rapport au centre de l'objet qui a les coordonnées (0, 0, 0). La première étape place l'objet 3D à une position dans la scène 3D, déterminée par le moteur physique, qui a des coordonnées (X, Y, Z). Une fois placé dans la scène 3D, le centre de l'objet passe donc des coordonnées (0, 0, 0) aux coordonnées (X, Y, Z) et tous les sommets de l'objet doivent être mis à jour. De plus, l'objet a une certaine orientation : il faut aussi le faire tourner. Enfin, l'objet peut aussi subir une mise à l'échelle : on peut le gonfler ou le faire rapetisser, du moment que cela ne modifie pas sa forme, mais simplement sa taille. En clair, le modèle 3D subit une translation, une rotation et une mise à l'échelle, les trois impliquant une modification des coordonnées des sommets.. [[File:Similarity and congruence transformations.svg|centre|vignette|upright=1.5|Transformations géométriques possibles pour chaque triangle.]] Une fois le placement des différents objets effectué, la carte graphique effectue un changement de coordonnées pour centrer le monde sur la caméra. Au lieu de considérer un des bords de la scène 3D comme étant le point de coordonnées (0, 0, 0), il va passer dans le référentiel de la caméra. Après cette transformation, le point de coordonnées (0, 0, 0) sera la caméra. La direction de la vue du joueur sera alignée avec l'axe de la profondeur (l'axe Z). [[File:View transform.svg|centre|vignette|upright=2|Étape de transformation dans un environnement en deux dimensions : avant et après. On voit que l'on centre le monde sur la position de la caméra et dans sa direction.]] Enfin, il faut aussi corriger la perspective, ce qui est le fait de l'étape de projection, qui modifie la forme du ''view frustum'' sans en modifier le contenu. Différents types de perspective existent et celles-ci ont un impact différent les unes des autres sur le ''view frustum''. Dans le cas qui nous intéresse, le ''view frustum'' passe d’une forme de trapèze tridimensionnel à une forme de pavé dont l'écran est une des faces. ===Les changements de coordonnées se font via des multiplications de matrices=== Les trois étapes précédentes demande de faire des changements de coordonnées, chaque sommet voyant ses coordonnées remplacées par de nouvelles. Or, un changement de coordonnée s'effectue assez simplement, avec des matrices, à savoir des tableaux organisés en lignes et en colonnes avec un nombre dans chaque case. Un changement de coordonnées se fait simplement en multipliant le vecteur (X, Y, Z) des coordonnées d'un sommet par une matrice adéquate. Il existe des matrices pour la translation, la mise à l'échelle, d'autres pour la rotation, une autre pour la transformation de la caméra, une autre pour l'étape de projection, etc. Un changement de coordonnée s'effectue assez simplement en multipliant le vecteur-coordonnées (X, Y, Z) d'un sommet par une matrice adéquate. Un petit problème est que les matrices qui le permettent sont des matrices avec 4 lignes et 4 colonnes. Or, la multiplication demande que le nombre de coordonnées du vecteur soit égal au nombre de colonnes. Pour résoudre ce petit problème, on ajoute une 4éme coordonnée aux sommets, la coordonnée homogène, qui ne sert à rien, et est souvent mise à 1, par défaut. Mais oublions ce détail. Il se trouve que multiplier des matrices amène certaines simplifications. Au lieu de faire plusieurs multiplications de matrices, il est possible de fusionner les matrices en une seule, ce qui permet de simplifier les calculs. Ce qui fait que le placement des objets, changement de repère pour centrer la caméra, et d'autres traitements forts différents sont regroupés ensemble. Le traitement de la géométrie implique, sans surprise, des calculs de géométrie dans l'espace. Et cela implique des opérations mathématiques aux noms barbares : produits scalaires, produits vectoriels, et autres calculs impliquant des vecteurs et/ou des matrices. Et les calculs vectoriels/matriciels impliquent beaucoup d'additions, de soustractions, de multiplications, de division, mais aussi des opérations plus complexes : calculs trigonométriques, racines carrées, inverse d'une racine carrée, etc. Au final, un simple processeur peut faire ce genre de calculs, si on lui fournit le programme adéquat, l'implémentation est assez aisée. Mais on peut aussi implémenter le tout avec un circuit spécialisé, non-programmable. Les deux solutions sont possibles, tant que le circuit dispose d'assez de puissance de calcul. Les cartes graphiques anciennes contenaient un ou plusieurs circuits de multiplication de matrices spécialisés dans l'étape de transformation. Chacun de ces circuits prend un sommet et renvoie le sommet transformé. Ils sont composés d'un gros paquet de multiplieurs et d'additionneurs flottants. Pour plus d'efficacité, les cartes graphiques comportent plusieurs de ces circuits, afin de pouvoir traiter plusieurs sommets en même temps. ==L'élimination des surfaces cachées== Un point important du rendu 3D est que ce que certaines portions de la scène 3D ne sont pas visibles depuis la caméra. Et idéalement, les portions de la scène 3D qui ne sont pas visibles à l'écran ne doivent pas être calculées. A quoi bon calculer des choses qui ne seront pas affichées ? Ce serait gâcher de la puissance de calcul. Et pour cela, de nombreuses optimisations visent à éliminer les calculs inutiles. Elles sont regroupées sous les termes de '''''clipping''''' ou de '''''culling'''''. La différence entre ''culling'' et ''clipping'' n'est pas fixée et la terminologie n'est pas claire. Dans ce qui va suivre, nous n'utiliserons que le terme ''culling''. Les cartes graphiques modernes embarquent diverses méthodes de ''culling'' pour abandonner les calculs quand elles s’aperçoivent que ceux-ci portent sur une partie non-affichée de l'image. Cela fait des économies de puissance de calcul assez appréciables et un gain en performance assez important. Précisons que le ''culling'' peut être plus ou moins précoce suivant le type de rendu 3D utilisé, mais nous verrons cela dans la suite du chapitre. ===Les différentes formes de ''culling''/''clipping''=== La première forme de ''culling'' est le '''''view frustum culling''''', dont le nom indique qu'il s'agit de l'élimination de tout ce qui est situé en-dehors du ''view frustum''. Ce qui est en-dehors du champ de vision de la caméra n'est pas affiché à l'écran n'est pas calculé ou rendu, dans une certaine mesure. Le ''view frustum culling'' est assez trivial : il suffit d'éliminer ce qui n'est pas dans le ''view frustum'' avec quelques calculs de coordonnées assez simples. Quelques subtilités surviennent quand un triangle est partiellement dans le ''view frustrum'', ce qui arrive parfois si le triangle est sur un bord de l'écran. Mais rien d'insurmontable. [[File:View frustum culling.svg|centre|vignette|upright=1|''View frustum culling'' : les parties potentiellement visibles sont en vert, celles invisibles en rouge et celles partiellement visibles en bleu.]] Les autres formes de ''culling'' visent à éliminer ce qui est dans le ''view frustum'', mais qui n'est pas visible depuis la caméra. Pensez à des objets cachés par un autre objet plus proche, par exemple. Ou encore, pensez aux faces à l'arrière d'un objet opaque qui sont cachées par l'avant. Ces deux cas correspondent à deux types de ''culling''. L'élimination des objets masqués par d'autres est appelé l'''occlusion culling''. L'élimination des parties arrières d'un objet est appelé le ''back-face culling''. Dans les deux cas, nous parlerons d''''élimination des surfaces cachées'''. [[File:Occlusion culling example PL.svg|centre|vignette|''Occlusion culling'' : les objets en bleu sont visibles, ceux en rouge sont masqués par les objets en bleu.]] Le lancer de rayons n'a pas besoin d'éliminer les surfaces cachées, il ne calcule que les surfaces visibles. Par contre, la rastérisation demande d'éliminer les surfaces cachées. Sans cela, le rendu est incorrect dans le pire des cas, ou alors le rendu calcule des surfaces invisibles pour rien. Il existe de nombreux algorithmes logiciels pour implémenter l'élimination des surfaces cachées, mais la carte graphique peut aussi s'en charger. L'''occlusion culling'' demande de connaitre la distance à la caméra de chaque triangle. La distance à la caméra est appelée la '''profondeur''' du triangle. Elle est déterminée à l'étape de rastérisation et est calculée à chaque sommet. Lors de la rastérisation, chaque sommet se voit attribuer trois coordonnées : deux coordonnées x et y qui indiquent sa position à l'écran, et une coordonnée de profondeur notée z. ===L'algorithme du peintre=== Pour éliminer les surfaces cachées, la solution la plus simple consiste simplement à rendre les triangles du plus lointain au plus proche. L'idée est que si deux triangles se recouvrent totalement ou partiellement, on doit dessiner celui qui est derrière, puis celui qui est devant. Le dessin du second va recouvrir le premier. Quelque chose qui devrait vous rappeler le rendu 2D, où les sprites sont rendus du plus lointain au plus proche. Il ne s'agit ni plus ni moins que de l''''algorithme du peintre'''. [[File:Polygons cross.svg|vignette|Polygons cross]] Un problème est que la solution ne marche pas avec certaines configurations particulières, dans le cas où des polygones un peu complexes se chevauchent plusieurs fois. Il se présente rarement dans un rendu 3D normal, mais c'est quand même un cas qu'il faut gérer. Le problème est suffisant pour que cette solution ne soit plus utilisée dans le rendu 3D normal. Un autre problème est que l'algorithme demande de trier les triangles d'une scène 3D selon leur profondeur, du plus profond au moins profond. Et les cartes graphiques n'aiment pas ça, que ce soit les anciennes cartes graphiques comme les modernes. Il s'agit généralement d'une tâche qui est réalisée par le processeur, le CPU, qui est plus efficace que le GPU pour trier des trucs. Aussi, l'algorithme du peintre était utilisé sur d'anciennes cartes graphiques, qui ne géraient pas la géométrie mais seulement les textures et quelques effets de post-processing. Avec ces GPU, les jeux vidéo calculaient la géométrie et la triait sur le CPU, puis effectuaient le reste de la rastérisation sur le GPU. Les anciens jeux en 2.5D comme DOOM ou les DOOM-like, utilisaient une amélioration de l'algorithme du peintre. L'amélioration variait suivant le moteur de jeu utilisé, et donnait soit une technique dite de ''portal rendering'', soit un système de ''Binary Space Partionning'', assez complexes et difficiles à expliquer. Mais il ne s'agissait pas de jeux en 3D, les maps de ces jeux avaient des contraintes qui rendaient cette technique utilisable. Ils n'avaient pas de polygones qui se chevauchent, notamment. ===Le tampon de profondeur=== [[File:Z-buffer no text.jpg|vignette|Z-buffer correspondant à un rendu]] Une autre solution utilise ce qu'on appelle un '''tampon de profondeur''', aussi appelé un ''z-buffer''. Il s'agit d'un tableau, stocké en mémoire vidéo, qui mémorise la coordonnée z de l'objet le plus proche pour chaque pixel. Par défaut, ce tampon de profondeur est initialisé avec la valeur de profondeur maximale, celle du ''far plane'' du ''viewfrustum''. Au fur et à mesure que les objets seront calculés, le tampon de profondeur est mis à jour, conservant ainsi la trace de l'objet le plus proche de la caméra. Si jamais un triangle a une coordonnée z plus grande que celle du tampon de profondeur, cela veut dire qu'il est situé derrière un objet déjà rendu. Il est éliminé (sauf si transparence il y a) et le tampon de profondeur n'a pas à être mis à jour. Dans le cas contraire, l'objet est plus près de la caméra et sa coordonnée z remplace l'ancienne valeur z dans le tampon de profondeur. [[File:Z-buffer.svg|centre|vignette|upright=2.0|Illustration du processus de mise à jour du Z-buffer.]] Il existe des techniques alternatives pour coder la coordonnée de profondeur, qui se distinguent par le fait que la coordonnée z n'est pas proportionnelle à la distance entre le fragment et la caméra. Mais il s'agit là de détails assez mathématiques que je me permets de passer sous silence. Dans la suite de ce cours, nous allons juste parler de profondeur pour regrouper toutes ces techniques, conventionnelles ou alternatives. Toutes les cartes graphiques modernes utilisent un système de ''z-buffer''. C'est la seule solution pour avoir des performances dignes de ce nom. Il faut cependant noter qu'elles utilisent des tampons de profondeur légèrement modifiés, qui ne mémorisent pas la coordonnée de profondeur, mais une valeur dérivée. Pour simplifier, ils ne mémorisent pas la coordonnée de profondeur z, mais son inverse 1/z. Les raisons à cela ne peuvent pas encore être expliquées à ce moment du cours, aussi nous allons simplement dire que c'est une histoire de correction de perspective. Les coordonnées z et 1/z sont codées sur quelques bits, allant de 16 bits pour les anciennes cartes graphiques, à 24/32 bits pour les cartes plus récentes. De nos jours, les Z-buffer de 16 bits sont abandonnés et toutes les cartes graphiques utilisent des coordonnées z de 24 à 32 bits. La raison est que les Z-buffer de 16 bits ont une précision insuffisante, ce qui fait que des artefacts peuvent survenir. Si deux objets sont suffisamment proches, le tampon de profondeur n'a pas la précision suffisante pour discriminer les deux objets. Pour lui, les deux objets sont à la même place. Conséquence : il faut bien choisir un des deux objets et ce choix se fait pixel par pixel, ce qui fait des artefacts visuels apparaissent. On parle alors de '''''z-fighting'''''. Voici ce que cela donne : [[File:Z-fighting.png|centre|vignette|Z-fighting]] Un défaut du tampon de profondeur est qu'il ne gère pas correctement les objets transparents. Dès que de la transparence est présente dans une scène 3D, le tampon de profondeur ne peut pas être utilisé. Une solution pour cela est de rendre une scène 3D en deux phases : une pour les objets opaques, une avec les objets transparents. La où on rend les objets opaques utilise le tampon de profondeur, mais il est désactivé lors de la seconde. ==La rastérisation et les textures== Dans cette section, nous allons voir ensemble l'étape de rastérisation et l'étape de traitement des pixels. La rastérisation est difficile à expliquer, surtout que son rôle exact dépend de la technique de rendu utilisée. Pour simplifier, elle projette un rendu en 3D sur un écran en 2D. Une autre explication tout aussi vague est qu'elle s'occupe la traduction des triangles en un affichage pixelisé à l'écran. Elle détermine à quoi ressemble la scène visible sur l'écran. C'est par exemple lors de cette étape que sont appliquées certaines techniques de ''culling'', qui éliminent les portions non-visibles de l'image, ainsi qu'une correction de la perspective et diverses opérations d'interpolation dont nous parlerons dans plusieurs chapitres. La rastérisation et placage de textures sont deux opérations très liées entre elles. Il existe deux manières principales pour lier les textures à la géométrie : la méthode directe et la méthode inverse (''UV Mapping''). Et les deux font que la rastérisation se fait de manière très différente. Précisons cependant que les rendus les plus simples n'utilisent pas de textures du tout. Ils se contentent de colorier les triangles, voire d'un simple rendu en fil de fer basé sur du tracé de lignes. Dans la suite de cette section, nous allons voir les quatre types de rendu principaux : le rendu en fils de fer, le rendu colorié, et deux rendus utilisant des textures. ===Le rendu en fil de fer=== [[File:Obj lineremoval.png|vignette|Rendu en fil de fer d'un objet 3D.]] Le '''rendu 3D en fils de fer''' est illustré ci-contre. Il s'agit d'un rendu assez ancien, utilisé au tout début de la 3D, sur des machines qu'on aurait du mal à appeler ordinateurs. Il se contente de tracer des lignes à l'écran, lignes qui connectent deux sommets, qui ne sont autres que les arêtes de la géométrie de la scène rendue. Le tout était suffisant pour réaliser quelques jeux vidéos rudimentaires. Les tout premiers jeux vidéos utilisaient ce rendu, l'un d'entre eux étant Maze War, le tout premier FPS. {| |[[File:Maze war.jpg|vignette|Maze war]] |[[File:Maze representation using wireframes 2022-01-10.gif|centre|vignette|Maze representation using wireframes 2022-01-10]] |} Le monde est calculé en 3D, il y a toujours un calcul de la géométrie, la scène est rastérisée normalement, les portions invisbles de l'image sont retirées, mais il n'y a pas d'application de textures après rastérisation. A la place, un algorithme de tracé de ligne trace les lignes à l'écran. Quand un triangle passe l'étape de rastérisation, l'étape de rastérisation fournit la position des trois sommets sur l'écran. En clair, elle fournit les coordonnées de trois pixels, un par sommet. A la suite, un algorithme de tracé de ligne trace trois lignes, une par paire de sommet. L'implémentation demande juste d'avoir une unité de calcul géométrique, une unité de rastérisation, et un VDC qui supporte le tracé de lignes. Elle est donc assez simple et ne demande pas de circuits de gestion des textures ni de ROP. Le VDC écrit directement dans le ''framebuffer'' les lignes à tracer. Il a existé des proto-cartes graphiques spécialisées dans ce genre de rendu, comme le '''''Line Drawing System-1''''' de l'entreprise Eans & Sutherland. Nous détaillerons son fonctionnement dans quelques chapitres. ===Le rendu à primitives colorées=== [[File:MiniFighter.png|vignette|upright=1|Exemple de rendu pouvant être obtenu avec des sommets colorés.]] Une amélioration du rendu précédent utilise des triangles/''quads'' coloriés. Chaque triangle ou ''quad'' est associé à une couleur, et cette couleur est dessinée sur le triangle/''quad''après la rastérisation. Le rendu est une amélioration du rendu en fils de fer. L'idée est que chaque triangle/''quad'' est associé à une couleur, qui est dessinée sur le triangle/''quad'' après la rastérisation. La technique est nommée ''colored vertices'' en anglais, nous parlerons de '''rendu à maillage coloré'''. [[File:Malla irregular de triángulos modelizando una superficie convexa.png|centre|vignette|upright=2|Maillage coloré.]] La couleur est propagée lors des calculs géométriques et de la rastérisation, sans subir de modifications. Une fois un rendu en fils de fer effectué, la couleur du triangle est récupérée. Le triangle/''quad'' rendu correspond à un triangle/''quad'' à l'écran. Et l'intérieur de ce triangle/''quad'' est colorié avec la couleur transmise. Pour cela, on utilise encore une fois une fonction du VDC : celle du remplissage de figure géométrique. Nous l’avions vu en parlant des VDC à accélération 2D, mais elle est souvent prise en charge par les ''blitters''. Ils peuvent remplir une figure géométrique avec une couleur unique, on réutilise cette fonction pour colorier le triangle/''quad''. L'étape de rastérisation fournit les coordonnées des sommets de la figure géométrique, le ''blitter'' les utilise pour colorier la figure géométrique. Niveau matériel, quelques bornes d'arcade ont utilisé ce rendu. La toute première borne d'arcade utilisant le rendu à maillage coloré est celle du jeu I Robot, d'Atari, sorti en 1983. Par la suite, dès 1988, les cartes d'arcades Namco System 21 et les bornes d'arcades Sega Model 1 utilisaient ce genre de rendu. On peut s'en rendre compte en regardant les graphismes des jeux tournant sur ces bornes d'arcade. Des jeux comme Virtua Racing, Virtua Fighter ou Virtua Formula sont assez parlants à ce niveau. Leurs graphismes sont assez anguleux et on voit qu'ils sont basés sur des triangles uniformément colorés. Pour ceux qui veulent en savoir plus sur la toute première borne d'arcade en rendu à maillage colorée, la borne ''I Robot'' d'Atari, voici une vidéo youtube à ce sujet : * [https://www.youtube.com/watch?v=6miEkPENsT0 I Robot d'Atari, le pionnier de la 3D Flat.] ===Le placage de textures direct=== Les deux rendus précédents sont très simples, mais n'utilisent pas de textures. Et il est temps de voir les deux rendus qui utilisent des textures. Il y en a deux types, appelés rendu avec placage de texture direct et indirect, nous allons voir le '''rendu par placage de texture direct''' en premier. Et nous l'appellerons ''rendu direct'' dans ce qui suit, pour simplifier les explications. L'idée est assez simple et peut utiliser aussi bien des triangles que des ''quads'', mais nous allons partir du principe qu'elle utilise des '''''quads''''', à savoir que les objets 3D sont composés de quadrilatères. Lorsqu'un ''quad'' est rastérisé, sa forme à l'écran est un rectangle déformé par la perspective. On obtient un rectangle si le ''quad'' est vu de face, un trapèze si on le voit de biais. Et le ''sprite'' doit être déformé de la même manière que le ''quad''. L'idée est que tout quad est associé à une texture, à un sprite. La figure géométrique qui correspond à un ''quad'' à l'écran est remplie non pas par une couleur uniforme, mais par un ''sprite'' rectangulaire. Il suffit techniquement de recopier le ''sprite'' à l'écran, c'est à dire dans la figure géométrique, au bon endroit dans le ''framebuffer''. Le rendu direct est en effet un intermédiaire entre rendu 2D à base de ''sprite'' et rendu 3D moderne. La géométrie est rendue en 3D pour générer des ''quads'', mais ces ''quads'' ne servent à guider la copie des sprites/textures dans le ''framebuffer''. [[File:TextureMapping.png|centre|vignette|upright=2|Exemple caricatural de placage de texture sur un ''quad''.]] La subtilité est que le sprite est déformé de manière à rentrer dans un quadrilatère, qui n'est pas forcément un rectangle à l'écran, mais est déformé par la perspective et son orientation en 3D. Le sprite doit être déformé de deux manières : il doit être agrandi/réduit en fonction de la taille de la figure affichée à l'écran, tourné en fonction de l'orientation du ''quad'', déformé pour gérer la perspective. Pour cela, il faut connaitre les coordonnées de profondeur de chaque bord d'un ''quad'', et de faire quelques calculs. N'importe quel VDC incluant un ''blitter'' avec une gestion du zoom/rotation des sprites peut le faire. : Si on veut avoir de beaux graphismes, il vaut mieux appliquer un filtre pour lisser le sprite envoyé dans le trapèze, filtre qui se résume à une opération d'interpolation et n'est pas très différent du filtrage de texture qui lisse les textures à l'écran. Un autre point est que les ''quads'' doivent être rendus du plus lointain au plus proche. Sans cela, on obtient rapidement des erreurs de rendu. L'idée est que si deux quads se chevauchent, on doit dessiner celui qui est derrière, puis celui qui est devant. Le dessin du second va recouvrir le premier. L'écriture du sprite du second quad écrasera les données du premier quad, pour les portions recouvertes, lors de l'écriture du sprite dans le ''framebuffer''. Quelque chose qui devrait vous rappeler le rendu 2D, où les sprites sont rendus du plus lointain au plus proche. Le rendu inverse utilise très souvent des triangles pour la géométrie, alors que le rendu direct a tendance à utiliser des ''quads'', mais il ne s'agit pas d'une différence stricte. L'usage de triangles/''quads'' peut se faire aussi bien avec un rendu direct comme avec un rendu inverse. Cependant, le rendu en ''quad'' se marie très bien au rendu direct, alors que le rendu en triangle colle mieux au rendu inverse. L'avantage de cette technique est qu'on parcourt les textures dans un ordre bien précis. Par exemple, on peut parcourir la texture ligne par ligne, l'exploiter par blocs de 4*4 pixels, etc. Et accéder à une texture de manière prédictible se marie bien avec l'usage de mémoires caches, ce qui est un avantage en matière de performances. Mais un même pixel du ''framebuffer'' est écrit plusieurs fois quand plusieurs quads se superposent, alors que le rendu inverse gère la situation avec une seule écriture (sauf si usage de la transparence). De plus, la gestion de la transparence était compliquée et les jeux devaient ruser en utilisation des solutions logicielles assez complexes. Niveau implémentation matérielle, une carte graphique en rendu direct demande juste trois circuits. Le premier est un circuit de calcul géométrique, qui rend la scène 3D. Le tri des quads est souvent réalisé par le processeur principal, et non pas par un circuit séparé. Toutes les étapes au-delà de l'étape de rastérisation étaient prises en charge par un VDC amélioré, qui écrivait des sprites/textures directement dans le ''framebuffer''. {|class="wikitable" |- ! Géométrie | Processeurs dédiés programmé pour émuler le pipeline graphique |- ! Tri des quads du plus lointain au plus proche | Processeur principal (implémentation logicielle) |- ! Application des textures | ''Blitter'' amélioré, capable de faire tourner et de zoomer sur des ''sprites''. |} L'implémentation était très simple et réutilisait des composants déjà existants : des VDC 2D pour l'application des textures, des processeurs dédiés pour la géométrie. Les unités de calcul de la géométrie étaient généralement implémentées avec un ou plusieurs processeurs dédiés. Vu qu'on savait déjà effectuer le rendu géométrique en logiciel, pas besoin de créer un circuit sur mesure. Il suffisait de dédier un processeur spécialisé rien que pour les calculs géométriques et on lui faisait exécuter un code déjà bien connu à la base. En clair, ils utilisaient un code spécifique pour émuler un circuit fixe. C'était clairement la solution la plus adaptée pour l'époque. Les unités géométriques étaient des processeurs RISC, normalement utilisés dans l'embarqué ou sur des serveurs. Elles utilisaient parfois des DSP. Pour rappel, les DSP des processeurs de traitement de signal assez communs, pas spécialement dédiés aux rendu 3D, mais spécialisé dans le traitement de signal audio, vidéo et autre. Ils avaient un jeu d'instruction assez proche de celui des cartes graphiques actuelles, et supportaient de nombreuses instructions utiles pour le rendu 3D. Le rendu direct a été utilisé dans la période de transition entre rendu 2D et rendu 3D, car il était très adapté pour faire cette transition. Coupler un VDC à un processeur pour la géométrie était particulièrement simple à l'époque. Le rendu direct a été utilisé sur des bornes d'arcade dès les années 90. Outre les bornes d'arcade, quelques consoles de 5ème génération utilisaient le rendu direct, avec les mêmes solutions matérielles. La géométrie était calculée sur plusieurs processeurs dédiés. Le reste du pipeline était géré par un VDC 2D qui implémentait le placage de textures. Deux consoles étaient dans ce cas : la 3DO, et la Sega Saturn. Le rendu direct est aujourd'hui abandonné. ===Le placage de textures inverse=== Le rendu précédent, le rendu direct, permet d'appliquer des textures directement dans le ''framebuffer''. Mais comme dit plus haut, il existe une seconde technique pour plaquer des textures, appelé le '''placage de texture inverse''', aussi appelé l'''UV Mapping''. Elle associe une texture complète pour un modèle 3D,contrairement au placage de tecture direct qui associe une texture par ''quad''/triangle. L'idée est que l'on attribue un texel à chaque sommet. Plus précisémment, chaque sommet est associé à des '''coordonnées de texture''', qui précisent quelle texture appliquer, mais aussi où se situe le texel à appliquer dans la texture. Par exemple, la coordonnée de texture peut dire : je veux le pixel qui est à ligne 5, colonne 27 dans cette texture. La correspondance entre texture et géométrie est réalisée lorsque les créateurs de jeu vidéo conçoivent le modèle de l'objet. [[File:Texture Mapping example.png|centre|vignette|upright=2|Exemple de placage de texture.]] Dans les faits, on n'utilise pas de coordonnées entières de ce type, mais deux nombres flottants compris entre 0 et 1. La coordonnée 0,0 correspond au texel en bas à gauche, celui de coordonnée 1,1 est tout en haut à droite. L'avantage est que ces coordonnées sont indépendantes de la résolution de la texture, ce qui aura des avantages pour certaines techniques de rendu, comme le ''mip-mapping''. Les deux coordonnées de texture sont notées u,v avec DirectX, ou encore s,t dans le cas général : u est la coordonnée horizontale, v la verticale. [[File:UVMapping.png|centre|vignette|upright=2|UV Mapping]] Avec le placage de texture inverse, la rastérisation se fait grosso-modo en trois étapes : la rastérisation proprement dite, le placage de textures, et les opérations finales qui écrivent un pixel dans le ''framebuffer''. Au niveau du matériel, ainsi que dans la plupart des API 3D, les trois étapes sont réalisées par des circuits séparés. [[File:01 3D-Rasterung-a.svg|vignette|Illustration du principe de la rasterization. La surface correspondant à l'écran est subdivisée en pixels carrés, de coordonnées x et y. La caméra est placée au point e. Pour chaque pixel, on trace une droite qui part de la caméra et qui passe par le pixel considéré. L'intersection entre une surface et cette droite se fait en un point, appartenant à un triangle.]] Lors de la rasterisation, chaque triangle se voit attribuer un ou plusieurs pixels à l'écran. Pour bien comprendre, imaginez une ligne droite qui part de caméra et qui passe par un pixel sur le plan de l'écran. Cette ligne intersecte 0, 1 ou plusieurs objets dans la scène 3D. Les triangles situés ces intersections entre cette ligne et les objets rencontrés seront associés au pixel correspondant. L'étape de rastérisation prend en entrée un triangle et renvoie la coordonnée x,y du pixel associé. Il s'agit là d'une simplification, car un triangle tend à occuper plusieurs pixels sur l'écran. L'étape de rastérisation fournit la liste de tous les pixels occupés par un triangle, et les traite un par un. Quand un triangle est rastérisé, le rasteriseur détermine la coordonnée x,y du premier pixel, applique une texture dessus, puis passe au suivant, et rebelote jusqu'à ce que tous les pixels occupés par le triangles aient été traités. L'implémentation matérielle du placage de texture inverse est beaucoup plus complexe que pour les autres techniques. Pour être franc, nous allons passer le reste du cours à parler de l'implémentation matérielle du placage de texture inverse, ce qui prendra plus d'une dizaine de chapitres. ==La transparence, les fragments et les ROPs== Dans ce qui suit, nous allons parler uniquement de la rastérisation avec placage de textures inverse. Les autres formes de rastérisation ne seront pas abordées. La raison est que tous les GPUs modernes utilisent cette forme de rastérisation, les exceptions étant rares. De même, ils utilisent un tampon de profondeur, pour l'élimination des surfaces cachées. La rastérisation effectue donc des calculs géométriques, suivis d'une étape de rastérisation, puis de placage des textures. Ces trois étapes sont réalisées par une unité géométrique, une unité de rastérisation, et un circuit de placage de textures. Du moins sur le principe, car les cartes graphiques modernes ont fortement optimisé l'implémentation et n'ont pas hésité à fusionner certains circuits. Mais nous verrons cela en temps voulu, nous n'allons pas résumer plusieurs décennies d'innovation technologique en quelques paragraphes. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Placage de textures |} Mais où mettre le tampon de profondeur ? Intuitivement, on se dit qu'il vaut mieux faire l'élimination des surfaces cachées le plus tôt possible, dès que la coordonnée de profondeur est connue. Et elle est connu à l'étape de rastérisation, une fois les sommets transformés. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Tampon de profondeur | Placage de textures |} En réalité, la profondeur des fragments est gérée par un circuit appelé le '''''Raster Operations Pipeline''''' (ROP), situé à la toute fin du pipeline graphique. Dans ce qui suit, nous utiliserons l'abréviation ROP pour simplifier les explications. Le ROP effectue quelques traitements sur les fragments, avant d'enregistrer l'image finale dans la mémoire vidéo. Il est placé à la fin du pipeline pour gérer correctement la transparence. Et nous allons voir pourquoi la transparence est gérée à la fin du pipeline. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Placage de textures | ''Raster Operations Pipeline'' |} ===Le mélange ''alpha''=== La transparence se manifeste quand plusieurs objets sont l'un derrière l'autre. Histoire de simplifier les explications, nous allons d'abord voir le cas où un objet semi-transparent est devant un objet opaque. La couleur perçue est alors un mélange de la couleur de l'objet opaque et celle de l'objet semi-transparent. Le mélange dépend d'à quel point l'objet semi-transparent est transparent. Avec un objet parfaitement transparent, seul l'objet opaque est visible. Avec un objet à moitié transparent, la couleur finale sera pour moitié celle de l'objet opaque, pour moitié celle de l'objet semi-transparent. Et c'est pareil pour les cas intermédiaires entre un objet totalement transparent et un objet totalement opaque. La transparence d'un objet/pixel est définie par un nombre, appelé la '''composante ''alpha'''''. Plus la composante alpha est élevée, plus le pixel est opaque. Elle vaut 0 pour un objet opaque et 1 pour un objet transparent. Elle est ajoutée aux composantes RGB, ce qui fait que tout fragment contient une "couleur de transparence" en plus des couleurs RGB. Elle agit comme un coefficient qui dit comment mélanger la couleur d'un objet transparent et d'un objet opaque. Le calcul de la transparence est une moyenne pondérée par la composante alpha. On parle alors d''''''alpha blending'''''. : <math>\text{Couleur finale} = \alpha \times \text{Couleur de l'objet transparent} + (1 - \alpha) \times \text{Couleur de l'objet opaque}</math> [[File:Texture splatting.png|centre|vignette|upright=2.0|Calcul de transparence. La première ligne montre le produit pour l'objet transparent, la seconde ligne est celle de l'objet opaque. La troisième ligne est celle de l'addition finale.]] Maintenant, qu'en est-il du cas où plusieurs objets sont superposés ? Si vous tracez une demi-droite dont l'origine est la caméra et qui passe par le pixel, il arrive qu'elle intersecte la géométrie en plusieurs points, un point par objet sur la ligne du regarde. Sans transparence, l'objet le plus proche cache tous les autres et c'est donc lui qui décide de la couleur du pixel. Mais avec un objet transparent, la couleur finale est un mélange de la couleur de plusieurs points d'intersection. Il faut donc calculer un pseudo-pixel pour chaque point d'intersection, auquel on donne le nom de '''fragment'''. Un fragment possède une position à l'écran, une coordonnée de profondeur, une couleur, ainsi que quelques autres informations potentiellement utiles. Les fragments attribués à un même pixel, qui sont à la même position sur l'écran, sont combinés pour obtenir la couleur finale de ce pixel. Il est possible d'utiliser le mélange ''alpha'' pour cela. Il suffit de faire le mélange ''alpha'' entre le fragment qui vient d'être calculé, et le pixel dans le ''framebuffer''. Pour cela, le fragment a une composante ''alpha'', qui est ajouté aux trois couleurs RGB. Le pixel déjà dans le ''framebuffer'' est un résultat temporaire, né du mélange ''alpha'' de tous les fragments précédents. Un défaut de cette méthode est qu'elle fonctionne assez mal avec un tampon de profondeur. Si le tampon de profondeur est activé, le mélange ''alpha'' ne fonctionne que si les objets sont rendus du plus lointain au plus proche. Et procéder dans cet ordre a un défaut : on dessine des objets dans le ''framebuffer'', pour qu'ensuite les objets devant écrasent ce qui a déjà été dessiné. Un même pixel peut donc être dessiné plusieurs fois, dont une seule sera pertinente. Et ces écritures utilisent de la bande passante mémoire, qui est une ressource précieuse sur un GPU moderne. Il s'agit d'un phénomène appelé '''''overdraw''''', ou sur-dessinage en français. Quelques optimisations permettent d'éliminer l'''overdraw'' en rendant les objets du plus proche au plus lointain, d'autres permettent de dessiner des objets dans un ordre arbitraire, mais nous ne pouvons pas en parler ici. Beaucoup de moteurs 3D rendent séparément les objets opaques et transparents. Une première passe rend les objets opaques, puis les objets transparents sont rendus dans une seconde passe. Les objets opaques sont rendus dans le désordre, ce qui fait qu'on n'a pas à les trier, alors que les objets transparents doivent être triés selon leur distance. un autre avantage est que le mélange ''alpha'' est désactivé lors de la première passe, alors que c'est la mise à jour du tampon de profondeur qui est désactivé lors de la seconde passe, ce qui augmente un peu les performances dans les deux cas. ===Le test ''alpha''=== Le test ''alpha'' est une technique qui permet d'annuler le rendu d'un fragment en fonction de sa transparence. Si la composante alpha est en-dessous ou au-dessus d'un seuil, le fragment est simplement abandonné. Le seuil en question est configurable, de même que la comparaison utilisée : on peut éliminer le fragment si sa transparence est au-dessus d'un certain seuil, en-dessous, égal, différent, etc. Il s'agit d'une optimisation qui est utile dans certains scénarios spécifiques. Par exemple, si l'objet a une transparence très élevée, du genre 95%, autant le compter comme complétement transparent, afin d'éviter des opérations de mélange ''alpha''. En effet, les opérations de mélange ''alpha'' sont très lentes, car elles demandent de faire des opérations de lecture-écriture en mémoire vidéo : on lit un pixel dans le ''framebuffer'', on applique le mélange ''alpha'' et on écrit le résultat en mémoire vidéo. L'''alpha test'' permet donc de gagner en performance au prix d'une baisse de la qualité d'image. Il y a cependant des cas où l'usage du test ''alpha'' est primordial, au-delà d'une question de performances. Un exemple classique est celui du rendu du feuillage dans un jeu 3D. Un feuillage est composé en assemblant plusieurs images de feuilles. Chaque feuille est un carré sur lequel on place une texture de feuille, qui est opaque pour la partie verte des feuilles, transparente pour le reste. Les carrés ne sont cependant pas superposés, mais s'intersectent fortement, ce qui fait que le mélange ''alpha'' ne donne pas de bons résultats. L'usage du test ''alpha'' permet d'obtenir un rendu correct. Pour d'informations via ce lien : * [https://bgolus.medium.com/anti-aliased-alpha-test-the-esoteric-alpha-to-coverage-8b177335ae4f Anti-aliased Alpha Test: The Esoteric Alpha To Coverage]. ===Les effets de brouillard=== Les '''effets de brouillard''' sont nécessaires dans certains jeux vidéo pour l'ambiance (pensez à des jeux d'horreur comme Silent Hill), mais ils ont surtout été utilisés pour économiser des calculs. L'idée est de ne pas calculer les graphismes au-delà d'une certaine distance, sans que cela se voie. Le ''view frustum'' utilise alors un plan limite, au-delà duquel on ne voit pas les objets. Mais ce plan limite donne une cassure inesthétique dans le rendu. Pour masquer cette cassure, les programmeurs ajoutaient un effet de brouillard. Les objets au-delà du plan limite étaient totalement dans le brouillard, puis ce brouillard se réduisait progressivement en se rapprochant de la caméra, avant de s'annuler à partir d'une certaine distance. Pour calculer le brouillard, on effectue un mélange ''alpha'' entre la couleur du pixel et une ''couleur de brouillard''. La différence est que l'on n'utilise pas la transparence pour faire le mélange, mais un '''coefficient de brouillard''', noté <math>\text{fog}(z)</math>. : <math>\text{Couleur finale} = \text{fog}(z) \times \text{Couleur de brouillard} + [ 1 - \text{fog}(z) ] \times \text{Couleur du pixel}</math> Le coefficient de brouillard dépend de la coordonnée de profondeur, de la distance du pixel par rapport à la caméra. Le brouillard démarre à une distance <math>z_{fog-start}</math>, et masque totalement les objets à partir d'une distance <math>z_{fog-end}</math>. Entre les deux, le coefficient de brouillard dépend de la distance. OpenGL autorise trois formules de calcul suivantes : : <math>\text{fog}(z) = \frac{z_{fog-end} - z}{z_{fog-end} - z_{fog-start}}</math> : <math>\text{fog}(z) = e^{- k \times z}</math> : <math>\text{fog}(z) = e^{- (k \times z)^2}</math> ==L'éclairage d'une scène 3D== L'éclairage d'une scène 3D calcule les ombres, mais aussi la luminosité de chaque pixel, ainsi que bien d'autres effets graphiques. Les algorithmes d'éclairage ont longtemps été implémentés directement en matériel, les cartes graphiques géraient l'éclairage dans des circuits spécialisés. Aussi, il est important de voir ces algorithmes d'éclairage. Il est possible d'implémenter l'éclairage à deux endroits différents du pipeline : juste avant la rastérisation, et après la rastérisation. ===Les sources de lumière et les couleurs associées=== L'éclairage d'une scène 3D provient de sources de lumières, comme des lampes, des torches, le soleil, etc. Il existe de nombreux types de sources de lumière, et nous n'allons parler que des principales. Elles sont au nombre de quatre et elles sont illustrées ci-dessous. [[File:3udUJ.gif|centre|vignette|upright=2|Types de sources de lumière.]] [[File:Graphics lightmodel directional.png|vignette|upright=1.0|Source de lumière directionnelle.]] Les '''sources directionnelles''' servent à modéliser des sources de lumière très éloignées, comme le soleil ou la lune. Elles sont simplement définies par un vecteur qui indique la direction de la lumière, rien de plus. Les '''sources ponctuelles''' sont des points, qui émettent de la lumière dans toutes les directions. Elles sont définies par une position, et une intensité lumineuse, éventuellement la couleur de la lumière émise. Il existe deyux types de sources de lumière ponctuelles. * Le premières émettent de manière égale dans toutes les directions. Elles sont appelées des ''point light'' dans le schéma du dessus. * Les secondes émettent de la lumière dans une '''direction privilégiée'''. L'exemple le plus parlant est celui d'une lampe-torche : elle émet de la lumière "tout droit", dans la direction où la lampe est orientée. Elles sont appelées des ''sport light'' dans le schéma du dessus. La direction privilégiée est un vecteur, notée v dans le schéma du dessous. [[File:Graphics lightmodel ambient.png|vignette|upright=1.0|Lumière ambiante.]] En théorie, la lumière rebondit sur les surfaces et a tendance à se disperser un peu partout à force de rebondir. C'est ce qui explique qu'on arrive à voir à l'intérieur d'une pièce si une fenêtre est ouverte. Il en résulte un certain '''éclairage ambiant''', qui est assez difficile à représenter dans un moteur de rendu 3D. Auparavant, l'éclairage ambiant était simulé par une lumière égale en tout point de la scène 3D, appelée simplement la '''lumière ambiante'''. Précisément, on suppose que la lumière ambiante en un point vient de toutes les directions et a une intensité constante, identique dans toutes les directions. Le tout est illustré ci-contre. C'est assez irréaliste, mais ça donne une bonne approximation de la lumière ambiante. ===La lumière incidente : le terme géométrique=== Pour simplifier, nous allons supposer que l'éclairage est calculé pour chaque sommet, pas par triangle. C'est de loin le cas le plus courant, aussi ce n'est pas une simplification abusive. La lumière qui arrive sur un sommet est appelée la '''lumière incidente'''. La couleur d'un sommet dépend de deux choses : la lumière incidente directe, comment il réfléchit cette lumière. Mathématiquement, il est possible de résumer cela avec le produit de deux termes : l'intensité de la lumière incidente, une fonction qui indique comment la surface réfléchit la lumière incidente. La fonction en question est appelée la '''réflectivité bidirectionnelle'''. Le terme anglais est ''bidirectional reflectance distribution function'', abrévié en BRDF, et nous utiliserons cette abréviation dans ce qui suit. : <math>\text{Couleur finale} = \text{Lumière incidente} \times BRDF(...)</math> La lumière incidente vient soit directement des sources de lumière, soit de la lumière qui a rebondit sur d'autres objets proches. La première est appelée la lumière directe, celle qui vient des rebonds s'appelle la lumière indirecte. Pour simplifier, la lumière indirecte est gérée par la lumière ambiante, nous passons sous silence les techniques d'illumination globale. En clair : nous allons nous limiter au cas où la lumière incidente vient directement d'une source de lumière, pas d'un rebond. Intuitivement, la lumière incidente est simplement égale à l'intensité de la source de lumière. Sauf que ce n'est qu'une approximation, et une assez mauvaise. En réalité, l'approximation est bonne si la lumière arrive proche de la verticale, mais elle est d'autant plus mauvaise que la lumière arrive penchée, voire rasante. La raison : la lumière incidente sera étalée sur une surface plus grande, si elle arrive penchée. Si vous vous souvenez de vos cours de collège, c'est le même principe qui explique les saisons. La lumière du soleil est proche de la verticale en été, mais est de plus en plus penché quand on s'avance vers l'Hiver. La lumière solaire est donc étalée sur une surface plus grande, ce qui fait qu'un point de la surface recevra moins de lumière, celle-ci étant diluée, étalée. [[File:Radiación solar.png|centre|vignette|upright=2|Exemple avec la lumière solaire.]] [[File:Angle of incidence.svg|vignette|upright=1|Angle d'incidence.]] En clair, tout dépend de l''''angle d'incidence''' de la lumière. Reste à voir comment calculer cet angle. La lumière incidente est définie par un vecteur, qui part de la source de lumière et atterrit sur le sommet considéré. Imaginez simplement que ce vecteur suit un rayon lumineux provenant de la source de lumière. Le vecteur pour la lumière incidente sera noté L. L'angle d'incidence est l'angle que fait ce vecteur avec la verticale de la surface, au niveau du sommet considéré. [[File:Graphics lightmodel ptsource.png|vignette|Normale de la surface.]] Pour cela, les calculs d'éclairage ont besoin de connaitre la verticale d'un sommet. Un sommet est donc associé à un vecteur, appelé la '''normale''', qui indique la verticale en ce point. Deux sommets différents peuvent avoir deux normales différentes, même s'ils sont proches. Elles sont d'autant plus différentes que la surface est rugueuse, non-lisse. La normale est prédéterminée lors de la création du modèle 3D, il n'y a pas besoin de le calculer. Par contre, elle est modifiée lors de l'étape de transformation, quand on place le modèle 3D dans la scène 3D. Les deux autres vecteurs sont à calculer à chaque image, car ils changent quand on bouge le sommet. La lumière qui arrive sur la surface dépend de l'angle entre la normale et le vecteur L. Précisément, elle dépend du cosinus de cet angle. En multipliant ce cosinus avec l'intensité de la lumière, on a la lumière arrivante. La couleur finale d'un pixel est donc : : <math>\text{Couleur finale} = I \times \cos{(N, L)} \times BRDF(...)</math> Le terme <math>I \times \cos{N, L}</math> ne dépend pas de la surface considérée. Juste de la position de la source de lumière, de la position du sommet et de son orientation par rapport à la lumière. Aussi, il est parfois appelé le '''terme géométrique''', en opposition aux propriétés de la surface. Les propriétés de la surface sont définies par un '''''material''''', qui indique comment il réfléchit la lumière, ainsi que sa texture. ===Le produit scalaire de deux vecteurs=== Calculer le terme géométrique demande de calculer le cosinus d'un angle. Et il n'est pas le seul : les autres calculs d'éclairage que nous allons voir demandent de calculer des cosinus. Or, les calculs trigonométriques sont très gourmands pour le GPU. Pour éviter le calcul d'un cosinus, les GPU utilisent une opération mathématique appelée le ''produit scalaire''. Le produit scalaire agit sur deux vecteurs, que l'on notera A et B. Un produit scalaire prend : la longueur des deux vecteurs, et l'angle entre les deux vecteurs noté <math>\omega</math>. Le produit scalaire est équivalent à la formule suivante : : <math>\text{Produit scalaire de deux vecteurs A et B} = \vec{A} \cdot \vec{B} = A \times B \times \cos{(\omega)}</math>, avec A et B la longueur des deux vecteurs A et B. L'avantage est que le produit scalaire se calcule simplement avec des additions, soustractions et multiplications, des opérations que les cartes graphiques savent faire très facilement. Le produit scalaire de deux vecteurs de coordonnées x,y,z est le suivant : : <math>\vec{A} \cdot \vec{B} = x_A \times x_B + y_A \times y_B + z_A \times z_B</math> En clair, on multiplie les coordonnées identiques, et on additionne les résultats. Rien de compliqué. Un avantage est que tous les vecteurs vus précédemment sont normalisés, à savoir qu'ils ont une longueur qui vaut 1. Ainsi, le calcul du produit scalaire devient équivalent au calcul du produit scalaire. ===La réflexion de la lumière sur la surface=== [[File:Ray Diagram 2.svg|vignette|Reflection de la lumière sur une surface parfaitement lisse.]] Maintenant que nous venons de voir le terme géométrique, voyons le BRDF, qui définit comment la surface de l'objet 3D réfléchit la lumière. Vos cours de collège vous ont sans doute appris que la lumière est réfléchie avec le même angle d'arrivée. L'angle d'incidence et l'angle de réflexion sont égaux, comme illustré ci-contre. On parle alors de '''réflexion parfaite'''. Mais cela ne vaut que pour une surface parfaitement lisse, comme un miroir parfait. Dans la réalité, une surface a tendance à renvoyer des rayons dans toutes les directions. La raison est qu'une surface réelle est rugueuse, avec de petites aspérités et des micro-reliefs, qui renvoient la lumière dans des directions "aléatoires". La lumière « rebondit » sur la surface de l'objet et une partie s'éparpille dans un peu toutes les directions. On parle alors de '''réflexion diffuse'''. {| |- |[[File:Dioptre reflexion diffuse speculaire refraction.svg|vignette|upright=1.4|Différence entre réflexion diffuse et spéculaire.]] |[[File:Diffuse reflection.svg|vignette|upright=1|Réflexion diffuse.]] |} Maintenant, imaginons que la surface n'ait qu'une réflexion diffuse, pas d'autres formes de réflexion. Et imaginons aussi que cette réflexion diffuse soit parfaite, à savoir que la lumière réfléchie soit renvoyée à l'identique dans toutes les directions, sans aucune direction privilégiée. On a alors le ''material'' le plus simple qui soit, appelé un '''''diffuse material'''''. Vu que la lumière est réfléchie à l'identique dans toutes les directions, elle sera identique peu importe où on place la caméra. La lumière finale ne dépend donc que des propriété de la surface, que de sa couleur. En clair, il suffit de donner une '''couleur diffuse''' à chaque sommet. La couleur diffuse est simplement multipliée par le terme géométrique, pour obtenir la lumière réfléchie finale. Rien de plus, rien de moins. Cela donne l'équation suivante, avec les termes suivants : * L est le vecteur pour la lumière incidente ; * N est la normale du sommet ; * I est l'intensité de la source de lumière ; * <math>C_d</math> est la couleur diffuse. : <math>\text{Illumination diffuse} = C_d \times \left[ I \times (\vec{N} \cdot \vec{L}) \right]</math> Rajoutons maintenant l'effet de la lumière ambiante à un ''material'' de ce genre. Pour rappel, la lumière ambiante vient de toutes les directions à part égale, ce qui fait que son angle d'incidence n'a donc pas d'effet. L'intensité de la lumière ambiante est déterminée lors de la création de la scène 3D, c'est une constante qui n'a pas à être calculée. Pour obtenir l'effet de la lumière ambiante sur un objet, il suffit de multiplier sa couleur diffuse par l'intensité de la lumière ambiante. Cependant, de nombreux moteurs de jeux ajoutent une '''couleur ambiante''', différente de la couleur diffuse. : <math>\text{Illumination ambiante} = C_a \times I_a</math> avec <math>C_a</math> la couleur ambiante du point de surface et <math>I_a</math> l'intensité de la lumière ambiante. En plus de la réflexion diffuse parfaite, de nombreux matériaux ajoutent une '''réflexion spéculaire''', qui n'est pas exactement la réflexion parfaite, en est très proche. Les rayons réfléchis sont très proches de la direction de réflexion parfaite, et s'atténuent très vite en s'en éloignant. Le résultat ressemble à une sorte de petit "point blanc", très lumineux, orienté vers la source de lumière, appelé le '''''specular highlight'''''. La réflexion diffuse est prédominante pour les matériaux rugueux, alors que la réflexion spéculaire est dominante sur les matériaux métalliques ou très lisses. [[File:Phong components version 4.png|centre|vignette|upright=3.0|Couleurs utilisées dans l'algorithme de Phong.]] [[File:Phong Vectors.svg|vignette|Vecteurs utilisés dans l'algorithme de Phong (et dans le calcul de l'éclairage, de manière générale).]] Pour calculer la réflexion spéculaire, il faut d'abord connaitre le vecteur pour la réflexion parfaite, que nous noterons R dans ce qui suit. Le vecteur R peut se calculer avec la formule ci-dessous : : <math>\vec{R} = 2 (\vec{L} \cdot \vec{N}) \times \vec{N} - \vec{L} </math> La réflexion spéculaire dépend de l'angle entre la direction du regard et la normale : plus celui-ci est proche de l'angle de réflexion parfaite, plus la réflexion spéculaire sera intense. Le vecteur pour la direction du regard sera noté V, pour vue ou vision. La réflexion spéculaire est une fonction qui dépend de l'angle entre les vecteurs R et V. Le calcul de la réflexion spéculaire utilise une '''couleur spéculaire''', qui est l'équivalent de la couleur diffuse pour la réflexion spéculaire. : <math>\text{BRDF spéculaire} = C_s \times f(\vec{R} \cdot \vec{V}) </math> La fonction varie grandement d'un modèle de calcul spéculaire à l'autre. Aussi, je ne rentre pas dans le détail. L'essentiel est que vous compreniez que le calcul de l'éclairage utilise de nombreux calculs géométriques, réalisés avec des produits scalaires. Les calculs géométriques utilisent la couleur d'un sommet, la normale du sommet, et le vecteur de la lumière incidente. Les autres informations sont calculées à l'exécution. ===Les algorithmes d'éclairage basiques : par triangle, par sommet et par pixel=== Dans tout ce qui a été dit précédemment, l'éclairage est calculé pour chaque sommet, ce qui fait qu'on parle d''''éclairage par sommet''', ou ''vertex lighting''. Il attribue une illumination/couleur à chaque sommet de la scène 3D. Mais une fois qu'on a obtenu la couleur des sommets, reste à colorier les triangles. Pour obtenir la couleur finale d'un pixel, l'éclairage par sommet fait une moyenne de la couleur de chaque sommet. La moyenne est une moyenne pondérée, qui tient compte de la distance du sommet avec le pixel. Plus le pixel est loin d'un sommet, plus son coefficient est petit. Typiquement, le coefficient varie entre 0 et 1 : de 1 si le pixel est sur le sommet, à 0 si le pixel est sur un des sommets adjacents. La moyenne effectuée est généralement une interpolation bilinéaire, qui est prise en charge par l'étape de rastérisation. L'éclairage par sommet a eu son heure de gloire, mais il est maintenant remplacé par l''''éclairage par pixel''' (''per-pixel lighting''), qui calcule l'éclairage pixel par pixel. En clair, l’éclairage est finalisé après l'étape de rastérisation, il ne se fait pas qu'au niveau de la géométrie. Il existe plusieurs types d'éclairage par pixel, mais on peut les classer en deux grands types : l'éclairage de Phong et le ''bump/normal mapping''. L''''éclairage de Phong''' calcule l'éclairage pixel par pixel. Avec cet algorithme, la géométrie n'est pas éclairée : les couleurs des sommets ne sont pas calculées. A la place, les normales sont envoyées à l'étape de rastérisation, qui effectue une opération d'interpolation, qui renvoie une normale pour chaque pixel. Les calculs d'éclairage utilisent alors ces normales pour faire les calculs d'éclairage pour chaque pixel. La technique du '''''normal mapping''''' est assez simple à expliquer, sans compter que plusieurs cartes graphiques l'ont implémentée directement dans leurs circuits. Là où l'éclairage de Phong interpole les normales pour chaque pixel, le ''normal-mapping'' précalcule les normales d'une surface dans une texture, appelée la ''normal-map''. Lors du rendu 3D, la carte graphique lit les normales adéquates directement depuis cette texture, puis fait les calculs d'éclairage avec. Le ''normal mapping'' permettent d'ajouter du relief et des détails sur des surfaces planes en jouant sur l'éclairage. Il permet de simplifier grandement la géométrie rendue, tout en utilisant l'éclairage pour compenser. [[File:WallSimpleAndNormalMapping.png|centre|vignette|upright=2|Différence sans et avec ''normal-mapping''.]] L'éclairage par pixel donne une qualité d'image supérieure à l'éclairage par sommet, mais il est aussi plus gourmand. Mais il est devenu la norme sur les jeux vidéos actuels. La différence entre l'éclairage par pixel et par sommet se voit assez facilement à l'écran. {| |- |[[File:Per face lighting.png|vignette|upright=1|Eclairage par triangle.]] |[[File:Per vertex lighting.png|vignette|upright=1|Eclairage par sommet.]] |[[File:Per fragment lighting.png|vignette|upright=1|Eclairage de Phong.]] |- |[[File:Per face lighting example.png|vignette|upright=1|Eclairage par triangle.]] |[[File:Per vertex lighting example.png|vignette|upright=1|Eclairage par sommet.]] |[[File:Per fragment lighting example.png|vignette|upright=1|Eclairage de Phong.]] |} ===Les ''shaders'' : des programmes exécutés sur le GPU=== Maintenant que nous venons de voir les algorithmes d'éclairages, il est temps de voir comment les réaliser sur une carte graphique. Nous venons de voir qu'il y a une différence entre l'éclairage par pixel et par sommet. Intuitivement, l'éclairage par sommet devrait se faire avec les calculs géométriques, alors que l'éclairage par pixel devrait se faire après avoir appliqué les textures. Les toutes premières cartes graphiques ne géraient ni l'éclairage par sommet, ni l'éclairage par pixel. Elles laissaient les calculs géométriques au CPU. Par la suite, la Geforce 256 a intégré '''circuit de ''Transform & Lightning''''', qui s'occupait de tous les calculs géométriques, éclairage par sommet inclus (d'où le L de T&L). Elle gérait alors l'éclairage par sommet, mais un algorithme particulier, qui n'était pas très flexible. Il ne gérait que des ''material'' bien précis (des ''Phong materials''), rien de plus. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Unité de T&L : géométrie | Rastérisation | Placage de textures | ''Raster Operations Pipeline'' |} L'amélioration suivante est venue sur la Geforce 3 : l'unité de T&L est devenue programmable. Au vu le grand nombre d'algorithmes d'éclairages possibles et le grand nombre de ''materials'' possibles, c'était la seule voie possibles. Les programmeurs pouvaient programmer leurs propres algorithmes d'éclairage par sommet, même s'ils devaient aussi programmer les étapes de transformation et de projection. Mais nous détaillerons cela dans un chapitre dédié sur l'historique des GPUs. Ce qui est important est que la Geforce 3 a introduit une fonctionnalité absolument cruciale pour le rendu 3D moderne : les '''''shaders'''''. Il s'agit de programmes informatiques exécutés par la carte graphique, qui servaient initialement à coder des algorithmes d'éclairage. D'où leur nom : ''shader'' pour ''shading'' (éclairage en anglais). Cependant, l'usage modernes des shaders dépasse le cadre des algorithmes d'éclairage. L'avantage est que cela simplifie grandement l'implémentation des algorithmes d'éclairage. Pas besoin de les intégrer dans la carte graphique pour les utiliser, pas besoin d'un circuit distinct pour chaque algorithme. Sans shaders, si la carte graphique ne gère pas un algorithme d'éclairage, on ne peut pas l'utiliser. A la rigueur, il est parfois possible de l'émuler avec des contournements logiciels, mais au prix de performances souvent désastreuses. Avec des shaders, il est possible de programmer l'algorithme d'éclairage de notre choix, pour l'exécuter sur la carte graphique, avec des performances plus que convenables. [[File:Implémentation de l'éclairage sur les cartes graphiques.png|vignette|Implémentation de l'éclairage sur les cartes graphiques]] Il existe plusieurs types de shaders, mais les deux principaux sont les '''''vertex shaders''''' et les '''''pixel shaders'''''. Les pixels shaders s'occupent de l'éclairage par pixel, leur nom est assez parlent. Les vertex shaders s'occupent de l'éclairage par sommet, mais aussi des étapes de transformation/projection. Je parle bien des trois étapes de transformation vues plus haut, qui effectuent des calculs de transformation de coordonnées avec des matrices. La raison à cela est que les calculs de transformation ressemblent beaucoup aux calculs d'éclairage par sommet. Ils impliquent tous deux des calculs vectoriels, comme des produits scalaires et des produits vectoriels, qui agissent sur des sommets/triangles. Si la carte graphique incorpore un processeur de shader capable de faire de tels calculs, alors il peut servir pour les deux. Pour implémenter les shaders, il a fallu ajouter des processeurs à la carte graphique. Les processeurs en question exécutent les shaders, ils peuvent lire ou écrire dans des textures, mais ne font rien d'autres. Les ''vertex shaders'' font tout ce qui a trait à la géométrie, ils remplacent l'unité de T&L. Les pixels shaders sont entre la rastérisation et les ROPs, ils sont très liés à l'unité de texture. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | rowspan="2" class="f_rouge" | ''Vertex shader'' | rowspan="2" | Rastérisation | Placage de textures | rowspan="2" |''Raster Operations Pipeline'' |- | class="f_rouge" | ''Pixel shader'' |} <noinclude> {{NavChapitre | book=Les cartes graphiques | prev=Les cartes d'affichage des anciens PC | prevText=Les cartes d'affichage des anciens PC | next=Avant les GPUs : les cartes accélératrices 3D | nextText=Avant les GPUs : les cartes accélératrices 3D }}{{autocat}} </noinclude> 6vzmxv1smg5mdhuo7fyr1r7d8akfozh 765150 765149 2026-04-26T20:14:13Z Mewtow 31375 /* Les algorithmes d'éclairage basiques : par triangle, par sommet et par pixel */ 765150 wikitext text/x-wiki Le premier jeu à utiliser de la "vraie 3D" texturée fut le jeu Quake, premier du nom. Et depuis sa sortie, la grande majorité des jeux vidéo utilisent de la 3D, même s'il existe encore quelques jeux en 2D. Face à la prolifération des jeux vidéo en 3D, les fabricants de cartes graphiques ont inventé les cartes accélératrices 3D, des cartes vidéo capables d'accélérer le rendu en 3D. Dans ce chapitre, nous allons voir comment elles fonctionnent et comment elles ont évolué dans le temps. Pour comprendre comment celles-ci fonctionnent, il faut faire quelques rapides rappels sur les bases du rendu 3D. ==Les bases du rendu 3D== Une '''scène 3D''' est composée d'un espace en trois dimensions, dans laquelle le moteur d’un jeu vidéo place des objets et les fait bouger. Cette scène est, en première approche, un simple parallélogramme. Un des coins de ce parallélogramme sert d’origine à un système de coordonnées : il est à la position (0, 0, 0), et les axes partent de ce point en suivant les arêtes. Les objets seront placés à des coordonnées bien précises dans ce parallélogramme. ===Les objets 3D et leur géométrie=== <noinclude>[[File:Dolphin triangle mesh.png|vignette|Illustration d'un dauphin, représenté avec des triangles.]]</noinclude> Dans la quasi-totalité des jeux vidéo actuels, les objets et la scène 3D sont modélisés par un assemblage de triangles collés les uns aux autres, ce qui porte le nom de '''maillage''', (''mesh'' en anglais). Il a été tenté dans le passé d'utiliser des quadrilatères (rendu dit en ''quad'') ou d'autres polygones, mais les contraintes techniques ont fait que ces solutions n'ont pas été retenues. [[File:CG WIKI.jpg|centre|vignette|upright=2|Exemple de modèle 3D.]] Les modèles 3D sont définis par leurs sommets, aussi appelés '''vertices''' dans le domaine du rendu 3D. Chaque sommet possède trois coordonnées, qui indiquent sa position dans la scène 3D : abscisse, ordonnée, profondeur. Les sommets sont regroupés en triangles, qui sont formés en combinant trois sommets entre eux. Les anciennes cartes graphiques géraient aussi d'autres formes géométriques, comme des points, des lignes, ou des quadrilatères. Les quadrilatères étaient appelés des ''quads'', et ce terme reviendra occasionnellement dans ce cours. De telles formes basiques, gérées nativement, sont appelées des '''primitives'''. La représentation exacte d'un objet est donc une liste plus ou moins structurée de sommets. La liste doit préciser les coordonnées de chaque sommet, ainsi que comment les relier pour former des triangles. Pour cela, l'objet est représenté par une structure qui contient la liste des sommets, mais aussi de quoi savoir quels sont les sommets reliés entre eux par un segment. Nous en dirons plus dans le chapitre sur le rendu de la géométrie. ===La caméra : le point de vue depuis l'écran=== Outre les objets proprement dit, on trouve une '''caméra''', qui représente les yeux du joueur. Cette caméra est définie au minimum par : * une position ; * par la direction du regard (un vecteur). A la caméra, il faut ajouter tout ce qui permet de déterminer le '''champ de vision'''. Le champ de vision contient tout ce qui est visible à l'écran. Et sa forme dépend de la perspective utilisée. Dans le cas le plus courant dans les jeux vidéos en 3D, il correspond à une '''pyramide de vision''' dont la pointe est la caméra, et dont les faces sont délimitées par les bords de l'écran. A l'intérieur de la pyramide, il y a un rectangle qui représente l'écran du joueur, appelé le '''''viewport'''''. <noinclude>[[File:ViewFrustum.jpg|centre|vignette|upright=2|Caméra.]]</noinclude> [[File:ViewFrustum.svg|vignette|upright=1|Volume délimité par la caméra (''view frustum'').]] La majorité des jeux vidéos ajoutent deux plans : * un ''near plane'' en-deça duquel les objets ne sont pas affichés. Il élimine du champ de vision les objets trop proches. * Un ''far plane'', un '''plan limite''' au-delà duquel on ne voit plus les objets. Il élimine les objets trop lointains. Avec ces deux plans, le champ de vision de la caméra est donc un volume en forme de pyramide tronquée, appelé le '''''view frustum'''''. Le tout est parfois appelée, bien que par abus de langage, la pyramide de vision. Avec d'autres perspectives moins utilisées, le ''view frustum'' est un pavé, mais nous n'en parlerons pas plus dans le cadre de ce cours car elles ne sont presque pas utilisés dans les jeux vidéos actuels. ===Les textures=== Tout objet à rendre en 3D est donc composé d'un assemblage de triangles, et ceux-ci sont éclairés et coloriés par divers algorithmes. Pour rajouter de la couleur, les objets sont recouverts par des '''textures''', des images qui servent de papier peint à un objet. Un objet géométrique est donc recouvert par une ou plusieurs textures qui permettent de le colorier ou de lui appliquer du relief. [[File:Texture+Mapping.jpg|centre|vignette|upright=2|Texture Mapping]] Notons que les textures sont des images comme les autres, codées pixel par pixel. Pour faire la différence entre les pixels de l'écran et les pixels d'une texture, on appelle ces derniers des '''texels'''. Ce terme est assez important, aussi profitez-en pour le mémoriser, nous le réutiliserons dans quelques chapitres. Un autre point lié au fait que les textures sont des images est leur compression, leur format. N'allez pas croire que les textures sont stockées dans un fichier .jpg, .png ou tout autre format de ce genre. Les textures utilisent des formats spécialisés, comme le DXTC1, le S3TC ou d'autres, plus adaptés à leur rôle de texture. Mais qu'il s'agisse d'images normales (.jpg, .png ou autres) ou de textures, toutes sont compressées. Les textures sont compressées pour prendre moins de mémoire. Songez que la compression de texture est terriblement efficace, souvent capable de diviser par 6 la mémoire occupée par une texture. S'en est au point où les textures restent compressées sur le disque dur, mais aussi dans la mémoire vidéo ! Nous en reparlerons dans le chapitre sur la mémoire d'une carte graphique. Plaquer une texture sur un objet peut se faire de deux manières, qui portent les noms de placage de texture inverse et direct. Le placage de texture direct a été utilisé au tout début de la 3D, sur des bornes d'arcade et les consoles de jeu 3DO, PS1, Sega Saturn. De nos jours, on utilise uniquement la technique de placage de texture inverse. Les deux seront décrites dans le détail plus bas. ===La différence entre rastérisation et lancer de rayons=== Les techniques de rendu 3D sont nombreuses, mais on peut les classer en deux grands types : le ''lancer de rayons'' et la ''rasterization''. Sans décrire les deux techniques, sachez cependant que le lancer de rayon n'est pas beaucoup utilisé pour les jeux vidéo. Il est surtout utilisé dans la production de films d'animation, d'effets spéciaux, ou d'autres rendu spéciaux. Dans les jeux vidéos, il est surtout utilisé pour quelques effets graphiques, la rasterization restant le mode de rendu principal. La raison principale est que le lancer de rayons demande beaucoup de puissance de calcul. Une autre raison est que créer des cartes accélératrices pour le lancer de rayons n'est pas simple. Il a existé des cartes accélératrices permettant d'accélérer le rendu en lancer de rayons, mais elles sont restées confidentielles. Les cartes graphiques modernes incorporent quelques circuits pour accélérer le lancer de rayons, mais ils restent d'un usage marginal et servent de compléments au rendu par rastérization. Un chapitre entier sera dédié aux cartes accélératrices de lancer de rayons et nous verrons pourquoi le lancer de rayons est difficile à implémenter avec des performances convenables, ce qui explique que les jeux vidéo utilisent la ''rasterization''. La rastérisation est structurée autour de trois étapes principales : * Une étape purement logicielle, effectuée par le processeur, où le moteur physique calcule la géométrie de la scène 3D. * Une étape de '''traitement de la géométrie''', qui gère tout ce qui a trait aux sommets et triangles. * Une étape de '''rastérisation''' qui détermine sur quels pixels de l'écran est affiché le triangle. * Une étape de '''traitement des pixels''', qui colorie les pixels et gère les textures. [[File:Graphics pipeline 2 en.svg|centre|vignette|upright=2.5|Pipeline graphique basique.]] Il existe plusieurs rendus différents et la rastérisation ne se fait pas de la même manière selon le rendu utilisé. Il existe des rendus sans textures, d'autres avec, d'autres avec éclairage, d'autres sans, etc. Par contre, l'étape de calcul de la géométrie est la même quel que soit le rendu ! Mieux : le calcul de la géométrie se fait de la même manière entre rastérisation et lancer de rayons, il est le même quelle que soit la technique de rendu 3D utilisée. Les trois étapes précédentes sont réalisées dans des circuits ou processeurs séparés, comme on le verra plus tard. Et cela permet d'utiliser la technique dite du '''pipeline'''. Concrètement, supposons que la carte graphique traite les données par paquets de triangles (en réalité, c'est des paquets de sommets, mais passons). L'étape de traitement de la géométrie peut travailler sur un paquet de triangle, pendant que le paquet précédent est dans l'étape de rastérisation, et que le paquet encore précédent est en train de traiter ses pixels. Cela permet de traiter trois paquets de triangles en même temps, mais à des états d'avancements différents. Mieux que cela : le traitement de la géométrie est lui-même composé d'une succession de sous-étapes, la rasterisation est elle-même découpée en plusieurs sous-étapes, et ainsi de suite. Le nombre d'étapes pour une carte graphique moderne dépasse la dizaine. La rastérisation calcule un rendu 3D avec une suite d'étapes consécutives qui doivent s'enchainer dans un ordre bien précis. L'ensemble de ces étapes est appelé le '''pipeline graphique''', qui sera détaillé dans ce qui suit. ==Le calcul de la géométrie== Le calcul de la géométrie regroupe plusieurs manipulations différentes. La principale demande juste de placer les modèles 3D dans la scène, de placer les objets dans le monde. Puis, il faut centrer la scène 3D sur la caméra. Les deux changements ont pour point commun de demander des changements de repères. Par changement de repères, on veut dire que l'on passe d'un système de coordonnées à un autre. En tout, il existe trois changements de repères distincts qui sont regroupés dans l''''étape de transformation''' : un premier qui place chaque objet 3D dans la scène 3D, un autre qui centre la scène du point de vue de la caméra, et un autre qui corrige la perspective. ===Les trois étapes de transformation=== La première étape place les objets 3D dans la scène 3D. Un modèle 3D est représentée par un ensemble de sommets, qui sont reliés pour former sa surface. Les données du modèle 3D indiquent, pour chaque sommet, sa position par rapport au centre de l'objet qui a les coordonnées (0, 0, 0). La première étape place l'objet 3D à une position dans la scène 3D, déterminée par le moteur physique, qui a des coordonnées (X, Y, Z). Une fois placé dans la scène 3D, le centre de l'objet passe donc des coordonnées (0, 0, 0) aux coordonnées (X, Y, Z) et tous les sommets de l'objet doivent être mis à jour. De plus, l'objet a une certaine orientation : il faut aussi le faire tourner. Enfin, l'objet peut aussi subir une mise à l'échelle : on peut le gonfler ou le faire rapetisser, du moment que cela ne modifie pas sa forme, mais simplement sa taille. En clair, le modèle 3D subit une translation, une rotation et une mise à l'échelle, les trois impliquant une modification des coordonnées des sommets.. [[File:Similarity and congruence transformations.svg|centre|vignette|upright=1.5|Transformations géométriques possibles pour chaque triangle.]] Une fois le placement des différents objets effectué, la carte graphique effectue un changement de coordonnées pour centrer le monde sur la caméra. Au lieu de considérer un des bords de la scène 3D comme étant le point de coordonnées (0, 0, 0), il va passer dans le référentiel de la caméra. Après cette transformation, le point de coordonnées (0, 0, 0) sera la caméra. La direction de la vue du joueur sera alignée avec l'axe de la profondeur (l'axe Z). [[File:View transform.svg|centre|vignette|upright=2|Étape de transformation dans un environnement en deux dimensions : avant et après. On voit que l'on centre le monde sur la position de la caméra et dans sa direction.]] Enfin, il faut aussi corriger la perspective, ce qui est le fait de l'étape de projection, qui modifie la forme du ''view frustum'' sans en modifier le contenu. Différents types de perspective existent et celles-ci ont un impact différent les unes des autres sur le ''view frustum''. Dans le cas qui nous intéresse, le ''view frustum'' passe d’une forme de trapèze tridimensionnel à une forme de pavé dont l'écran est une des faces. ===Les changements de coordonnées se font via des multiplications de matrices=== Les trois étapes précédentes demande de faire des changements de coordonnées, chaque sommet voyant ses coordonnées remplacées par de nouvelles. Or, un changement de coordonnée s'effectue assez simplement, avec des matrices, à savoir des tableaux organisés en lignes et en colonnes avec un nombre dans chaque case. Un changement de coordonnées se fait simplement en multipliant le vecteur (X, Y, Z) des coordonnées d'un sommet par une matrice adéquate. Il existe des matrices pour la translation, la mise à l'échelle, d'autres pour la rotation, une autre pour la transformation de la caméra, une autre pour l'étape de projection, etc. Un changement de coordonnée s'effectue assez simplement en multipliant le vecteur-coordonnées (X, Y, Z) d'un sommet par une matrice adéquate. Un petit problème est que les matrices qui le permettent sont des matrices avec 4 lignes et 4 colonnes. Or, la multiplication demande que le nombre de coordonnées du vecteur soit égal au nombre de colonnes. Pour résoudre ce petit problème, on ajoute une 4éme coordonnée aux sommets, la coordonnée homogène, qui ne sert à rien, et est souvent mise à 1, par défaut. Mais oublions ce détail. Il se trouve que multiplier des matrices amène certaines simplifications. Au lieu de faire plusieurs multiplications de matrices, il est possible de fusionner les matrices en une seule, ce qui permet de simplifier les calculs. Ce qui fait que le placement des objets, changement de repère pour centrer la caméra, et d'autres traitements forts différents sont regroupés ensemble. Le traitement de la géométrie implique, sans surprise, des calculs de géométrie dans l'espace. Et cela implique des opérations mathématiques aux noms barbares : produits scalaires, produits vectoriels, et autres calculs impliquant des vecteurs et/ou des matrices. Et les calculs vectoriels/matriciels impliquent beaucoup d'additions, de soustractions, de multiplications, de division, mais aussi des opérations plus complexes : calculs trigonométriques, racines carrées, inverse d'une racine carrée, etc. Au final, un simple processeur peut faire ce genre de calculs, si on lui fournit le programme adéquat, l'implémentation est assez aisée. Mais on peut aussi implémenter le tout avec un circuit spécialisé, non-programmable. Les deux solutions sont possibles, tant que le circuit dispose d'assez de puissance de calcul. Les cartes graphiques anciennes contenaient un ou plusieurs circuits de multiplication de matrices spécialisés dans l'étape de transformation. Chacun de ces circuits prend un sommet et renvoie le sommet transformé. Ils sont composés d'un gros paquet de multiplieurs et d'additionneurs flottants. Pour plus d'efficacité, les cartes graphiques comportent plusieurs de ces circuits, afin de pouvoir traiter plusieurs sommets en même temps. ==L'élimination des surfaces cachées== Un point important du rendu 3D est que ce que certaines portions de la scène 3D ne sont pas visibles depuis la caméra. Et idéalement, les portions de la scène 3D qui ne sont pas visibles à l'écran ne doivent pas être calculées. A quoi bon calculer des choses qui ne seront pas affichées ? Ce serait gâcher de la puissance de calcul. Et pour cela, de nombreuses optimisations visent à éliminer les calculs inutiles. Elles sont regroupées sous les termes de '''''clipping''''' ou de '''''culling'''''. La différence entre ''culling'' et ''clipping'' n'est pas fixée et la terminologie n'est pas claire. Dans ce qui va suivre, nous n'utiliserons que le terme ''culling''. Les cartes graphiques modernes embarquent diverses méthodes de ''culling'' pour abandonner les calculs quand elles s’aperçoivent que ceux-ci portent sur une partie non-affichée de l'image. Cela fait des économies de puissance de calcul assez appréciables et un gain en performance assez important. Précisons que le ''culling'' peut être plus ou moins précoce suivant le type de rendu 3D utilisé, mais nous verrons cela dans la suite du chapitre. ===Les différentes formes de ''culling''/''clipping''=== La première forme de ''culling'' est le '''''view frustum culling''''', dont le nom indique qu'il s'agit de l'élimination de tout ce qui est situé en-dehors du ''view frustum''. Ce qui est en-dehors du champ de vision de la caméra n'est pas affiché à l'écran n'est pas calculé ou rendu, dans une certaine mesure. Le ''view frustum culling'' est assez trivial : il suffit d'éliminer ce qui n'est pas dans le ''view frustum'' avec quelques calculs de coordonnées assez simples. Quelques subtilités surviennent quand un triangle est partiellement dans le ''view frustrum'', ce qui arrive parfois si le triangle est sur un bord de l'écran. Mais rien d'insurmontable. [[File:View frustum culling.svg|centre|vignette|upright=1|''View frustum culling'' : les parties potentiellement visibles sont en vert, celles invisibles en rouge et celles partiellement visibles en bleu.]] Les autres formes de ''culling'' visent à éliminer ce qui est dans le ''view frustum'', mais qui n'est pas visible depuis la caméra. Pensez à des objets cachés par un autre objet plus proche, par exemple. Ou encore, pensez aux faces à l'arrière d'un objet opaque qui sont cachées par l'avant. Ces deux cas correspondent à deux types de ''culling''. L'élimination des objets masqués par d'autres est appelé l'''occlusion culling''. L'élimination des parties arrières d'un objet est appelé le ''back-face culling''. Dans les deux cas, nous parlerons d''''élimination des surfaces cachées'''. [[File:Occlusion culling example PL.svg|centre|vignette|''Occlusion culling'' : les objets en bleu sont visibles, ceux en rouge sont masqués par les objets en bleu.]] Le lancer de rayons n'a pas besoin d'éliminer les surfaces cachées, il ne calcule que les surfaces visibles. Par contre, la rastérisation demande d'éliminer les surfaces cachées. Sans cela, le rendu est incorrect dans le pire des cas, ou alors le rendu calcule des surfaces invisibles pour rien. Il existe de nombreux algorithmes logiciels pour implémenter l'élimination des surfaces cachées, mais la carte graphique peut aussi s'en charger. L'''occlusion culling'' demande de connaitre la distance à la caméra de chaque triangle. La distance à la caméra est appelée la '''profondeur''' du triangle. Elle est déterminée à l'étape de rastérisation et est calculée à chaque sommet. Lors de la rastérisation, chaque sommet se voit attribuer trois coordonnées : deux coordonnées x et y qui indiquent sa position à l'écran, et une coordonnée de profondeur notée z. ===L'algorithme du peintre=== Pour éliminer les surfaces cachées, la solution la plus simple consiste simplement à rendre les triangles du plus lointain au plus proche. L'idée est que si deux triangles se recouvrent totalement ou partiellement, on doit dessiner celui qui est derrière, puis celui qui est devant. Le dessin du second va recouvrir le premier. Quelque chose qui devrait vous rappeler le rendu 2D, où les sprites sont rendus du plus lointain au plus proche. Il ne s'agit ni plus ni moins que de l''''algorithme du peintre'''. [[File:Polygons cross.svg|vignette|Polygons cross]] Un problème est que la solution ne marche pas avec certaines configurations particulières, dans le cas où des polygones un peu complexes se chevauchent plusieurs fois. Il se présente rarement dans un rendu 3D normal, mais c'est quand même un cas qu'il faut gérer. Le problème est suffisant pour que cette solution ne soit plus utilisée dans le rendu 3D normal. Un autre problème est que l'algorithme demande de trier les triangles d'une scène 3D selon leur profondeur, du plus profond au moins profond. Et les cartes graphiques n'aiment pas ça, que ce soit les anciennes cartes graphiques comme les modernes. Il s'agit généralement d'une tâche qui est réalisée par le processeur, le CPU, qui est plus efficace que le GPU pour trier des trucs. Aussi, l'algorithme du peintre était utilisé sur d'anciennes cartes graphiques, qui ne géraient pas la géométrie mais seulement les textures et quelques effets de post-processing. Avec ces GPU, les jeux vidéo calculaient la géométrie et la triait sur le CPU, puis effectuaient le reste de la rastérisation sur le GPU. Les anciens jeux en 2.5D comme DOOM ou les DOOM-like, utilisaient une amélioration de l'algorithme du peintre. L'amélioration variait suivant le moteur de jeu utilisé, et donnait soit une technique dite de ''portal rendering'', soit un système de ''Binary Space Partionning'', assez complexes et difficiles à expliquer. Mais il ne s'agissait pas de jeux en 3D, les maps de ces jeux avaient des contraintes qui rendaient cette technique utilisable. Ils n'avaient pas de polygones qui se chevauchent, notamment. ===Le tampon de profondeur=== [[File:Z-buffer no text.jpg|vignette|Z-buffer correspondant à un rendu]] Une autre solution utilise ce qu'on appelle un '''tampon de profondeur''', aussi appelé un ''z-buffer''. Il s'agit d'un tableau, stocké en mémoire vidéo, qui mémorise la coordonnée z de l'objet le plus proche pour chaque pixel. Par défaut, ce tampon de profondeur est initialisé avec la valeur de profondeur maximale, celle du ''far plane'' du ''viewfrustum''. Au fur et à mesure que les objets seront calculés, le tampon de profondeur est mis à jour, conservant ainsi la trace de l'objet le plus proche de la caméra. Si jamais un triangle a une coordonnée z plus grande que celle du tampon de profondeur, cela veut dire qu'il est situé derrière un objet déjà rendu. Il est éliminé (sauf si transparence il y a) et le tampon de profondeur n'a pas à être mis à jour. Dans le cas contraire, l'objet est plus près de la caméra et sa coordonnée z remplace l'ancienne valeur z dans le tampon de profondeur. [[File:Z-buffer.svg|centre|vignette|upright=2.0|Illustration du processus de mise à jour du Z-buffer.]] Il existe des techniques alternatives pour coder la coordonnée de profondeur, qui se distinguent par le fait que la coordonnée z n'est pas proportionnelle à la distance entre le fragment et la caméra. Mais il s'agit là de détails assez mathématiques que je me permets de passer sous silence. Dans la suite de ce cours, nous allons juste parler de profondeur pour regrouper toutes ces techniques, conventionnelles ou alternatives. Toutes les cartes graphiques modernes utilisent un système de ''z-buffer''. C'est la seule solution pour avoir des performances dignes de ce nom. Il faut cependant noter qu'elles utilisent des tampons de profondeur légèrement modifiés, qui ne mémorisent pas la coordonnée de profondeur, mais une valeur dérivée. Pour simplifier, ils ne mémorisent pas la coordonnée de profondeur z, mais son inverse 1/z. Les raisons à cela ne peuvent pas encore être expliquées à ce moment du cours, aussi nous allons simplement dire que c'est une histoire de correction de perspective. Les coordonnées z et 1/z sont codées sur quelques bits, allant de 16 bits pour les anciennes cartes graphiques, à 24/32 bits pour les cartes plus récentes. De nos jours, les Z-buffer de 16 bits sont abandonnés et toutes les cartes graphiques utilisent des coordonnées z de 24 à 32 bits. La raison est que les Z-buffer de 16 bits ont une précision insuffisante, ce qui fait que des artefacts peuvent survenir. Si deux objets sont suffisamment proches, le tampon de profondeur n'a pas la précision suffisante pour discriminer les deux objets. Pour lui, les deux objets sont à la même place. Conséquence : il faut bien choisir un des deux objets et ce choix se fait pixel par pixel, ce qui fait des artefacts visuels apparaissent. On parle alors de '''''z-fighting'''''. Voici ce que cela donne : [[File:Z-fighting.png|centre|vignette|Z-fighting]] Un défaut du tampon de profondeur est qu'il ne gère pas correctement les objets transparents. Dès que de la transparence est présente dans une scène 3D, le tampon de profondeur ne peut pas être utilisé. Une solution pour cela est de rendre une scène 3D en deux phases : une pour les objets opaques, une avec les objets transparents. La où on rend les objets opaques utilise le tampon de profondeur, mais il est désactivé lors de la seconde. ==La rastérisation et les textures== Dans cette section, nous allons voir ensemble l'étape de rastérisation et l'étape de traitement des pixels. La rastérisation est difficile à expliquer, surtout que son rôle exact dépend de la technique de rendu utilisée. Pour simplifier, elle projette un rendu en 3D sur un écran en 2D. Une autre explication tout aussi vague est qu'elle s'occupe la traduction des triangles en un affichage pixelisé à l'écran. Elle détermine à quoi ressemble la scène visible sur l'écran. C'est par exemple lors de cette étape que sont appliquées certaines techniques de ''culling'', qui éliminent les portions non-visibles de l'image, ainsi qu'une correction de la perspective et diverses opérations d'interpolation dont nous parlerons dans plusieurs chapitres. La rastérisation et placage de textures sont deux opérations très liées entre elles. Il existe deux manières principales pour lier les textures à la géométrie : la méthode directe et la méthode inverse (''UV Mapping''). Et les deux font que la rastérisation se fait de manière très différente. Précisons cependant que les rendus les plus simples n'utilisent pas de textures du tout. Ils se contentent de colorier les triangles, voire d'un simple rendu en fil de fer basé sur du tracé de lignes. Dans la suite de cette section, nous allons voir les quatre types de rendu principaux : le rendu en fils de fer, le rendu colorié, et deux rendus utilisant des textures. ===Le rendu en fil de fer=== [[File:Obj lineremoval.png|vignette|Rendu en fil de fer d'un objet 3D.]] Le '''rendu 3D en fils de fer''' est illustré ci-contre. Il s'agit d'un rendu assez ancien, utilisé au tout début de la 3D, sur des machines qu'on aurait du mal à appeler ordinateurs. Il se contente de tracer des lignes à l'écran, lignes qui connectent deux sommets, qui ne sont autres que les arêtes de la géométrie de la scène rendue. Le tout était suffisant pour réaliser quelques jeux vidéos rudimentaires. Les tout premiers jeux vidéos utilisaient ce rendu, l'un d'entre eux étant Maze War, le tout premier FPS. {| |[[File:Maze war.jpg|vignette|Maze war]] |[[File:Maze representation using wireframes 2022-01-10.gif|centre|vignette|Maze representation using wireframes 2022-01-10]] |} Le monde est calculé en 3D, il y a toujours un calcul de la géométrie, la scène est rastérisée normalement, les portions invisbles de l'image sont retirées, mais il n'y a pas d'application de textures après rastérisation. A la place, un algorithme de tracé de ligne trace les lignes à l'écran. Quand un triangle passe l'étape de rastérisation, l'étape de rastérisation fournit la position des trois sommets sur l'écran. En clair, elle fournit les coordonnées de trois pixels, un par sommet. A la suite, un algorithme de tracé de ligne trace trois lignes, une par paire de sommet. L'implémentation demande juste d'avoir une unité de calcul géométrique, une unité de rastérisation, et un VDC qui supporte le tracé de lignes. Elle est donc assez simple et ne demande pas de circuits de gestion des textures ni de ROP. Le VDC écrit directement dans le ''framebuffer'' les lignes à tracer. Il a existé des proto-cartes graphiques spécialisées dans ce genre de rendu, comme le '''''Line Drawing System-1''''' de l'entreprise Eans & Sutherland. Nous détaillerons son fonctionnement dans quelques chapitres. ===Le rendu à primitives colorées=== [[File:MiniFighter.png|vignette|upright=1|Exemple de rendu pouvant être obtenu avec des sommets colorés.]] Une amélioration du rendu précédent utilise des triangles/''quads'' coloriés. Chaque triangle ou ''quad'' est associé à une couleur, et cette couleur est dessinée sur le triangle/''quad''après la rastérisation. Le rendu est une amélioration du rendu en fils de fer. L'idée est que chaque triangle/''quad'' est associé à une couleur, qui est dessinée sur le triangle/''quad'' après la rastérisation. La technique est nommée ''colored vertices'' en anglais, nous parlerons de '''rendu à maillage coloré'''. [[File:Malla irregular de triángulos modelizando una superficie convexa.png|centre|vignette|upright=2|Maillage coloré.]] La couleur est propagée lors des calculs géométriques et de la rastérisation, sans subir de modifications. Une fois un rendu en fils de fer effectué, la couleur du triangle est récupérée. Le triangle/''quad'' rendu correspond à un triangle/''quad'' à l'écran. Et l'intérieur de ce triangle/''quad'' est colorié avec la couleur transmise. Pour cela, on utilise encore une fois une fonction du VDC : celle du remplissage de figure géométrique. Nous l’avions vu en parlant des VDC à accélération 2D, mais elle est souvent prise en charge par les ''blitters''. Ils peuvent remplir une figure géométrique avec une couleur unique, on réutilise cette fonction pour colorier le triangle/''quad''. L'étape de rastérisation fournit les coordonnées des sommets de la figure géométrique, le ''blitter'' les utilise pour colorier la figure géométrique. Niveau matériel, quelques bornes d'arcade ont utilisé ce rendu. La toute première borne d'arcade utilisant le rendu à maillage coloré est celle du jeu I Robot, d'Atari, sorti en 1983. Par la suite, dès 1988, les cartes d'arcades Namco System 21 et les bornes d'arcades Sega Model 1 utilisaient ce genre de rendu. On peut s'en rendre compte en regardant les graphismes des jeux tournant sur ces bornes d'arcade. Des jeux comme Virtua Racing, Virtua Fighter ou Virtua Formula sont assez parlants à ce niveau. Leurs graphismes sont assez anguleux et on voit qu'ils sont basés sur des triangles uniformément colorés. Pour ceux qui veulent en savoir plus sur la toute première borne d'arcade en rendu à maillage colorée, la borne ''I Robot'' d'Atari, voici une vidéo youtube à ce sujet : * [https://www.youtube.com/watch?v=6miEkPENsT0 I Robot d'Atari, le pionnier de la 3D Flat.] ===Le placage de textures direct=== Les deux rendus précédents sont très simples, mais n'utilisent pas de textures. Et il est temps de voir les deux rendus qui utilisent des textures. Il y en a deux types, appelés rendu avec placage de texture direct et indirect, nous allons voir le '''rendu par placage de texture direct''' en premier. Et nous l'appellerons ''rendu direct'' dans ce qui suit, pour simplifier les explications. L'idée est assez simple et peut utiliser aussi bien des triangles que des ''quads'', mais nous allons partir du principe qu'elle utilise des '''''quads''''', à savoir que les objets 3D sont composés de quadrilatères. Lorsqu'un ''quad'' est rastérisé, sa forme à l'écran est un rectangle déformé par la perspective. On obtient un rectangle si le ''quad'' est vu de face, un trapèze si on le voit de biais. Et le ''sprite'' doit être déformé de la même manière que le ''quad''. L'idée est que tout quad est associé à une texture, à un sprite. La figure géométrique qui correspond à un ''quad'' à l'écran est remplie non pas par une couleur uniforme, mais par un ''sprite'' rectangulaire. Il suffit techniquement de recopier le ''sprite'' à l'écran, c'est à dire dans la figure géométrique, au bon endroit dans le ''framebuffer''. Le rendu direct est en effet un intermédiaire entre rendu 2D à base de ''sprite'' et rendu 3D moderne. La géométrie est rendue en 3D pour générer des ''quads'', mais ces ''quads'' ne servent à guider la copie des sprites/textures dans le ''framebuffer''. [[File:TextureMapping.png|centre|vignette|upright=2|Exemple caricatural de placage de texture sur un ''quad''.]] La subtilité est que le sprite est déformé de manière à rentrer dans un quadrilatère, qui n'est pas forcément un rectangle à l'écran, mais est déformé par la perspective et son orientation en 3D. Le sprite doit être déformé de deux manières : il doit être agrandi/réduit en fonction de la taille de la figure affichée à l'écran, tourné en fonction de l'orientation du ''quad'', déformé pour gérer la perspective. Pour cela, il faut connaitre les coordonnées de profondeur de chaque bord d'un ''quad'', et de faire quelques calculs. N'importe quel VDC incluant un ''blitter'' avec une gestion du zoom/rotation des sprites peut le faire. : Si on veut avoir de beaux graphismes, il vaut mieux appliquer un filtre pour lisser le sprite envoyé dans le trapèze, filtre qui se résume à une opération d'interpolation et n'est pas très différent du filtrage de texture qui lisse les textures à l'écran. Un autre point est que les ''quads'' doivent être rendus du plus lointain au plus proche. Sans cela, on obtient rapidement des erreurs de rendu. L'idée est que si deux quads se chevauchent, on doit dessiner celui qui est derrière, puis celui qui est devant. Le dessin du second va recouvrir le premier. L'écriture du sprite du second quad écrasera les données du premier quad, pour les portions recouvertes, lors de l'écriture du sprite dans le ''framebuffer''. Quelque chose qui devrait vous rappeler le rendu 2D, où les sprites sont rendus du plus lointain au plus proche. Le rendu inverse utilise très souvent des triangles pour la géométrie, alors que le rendu direct a tendance à utiliser des ''quads'', mais il ne s'agit pas d'une différence stricte. L'usage de triangles/''quads'' peut se faire aussi bien avec un rendu direct comme avec un rendu inverse. Cependant, le rendu en ''quad'' se marie très bien au rendu direct, alors que le rendu en triangle colle mieux au rendu inverse. L'avantage de cette technique est qu'on parcourt les textures dans un ordre bien précis. Par exemple, on peut parcourir la texture ligne par ligne, l'exploiter par blocs de 4*4 pixels, etc. Et accéder à une texture de manière prédictible se marie bien avec l'usage de mémoires caches, ce qui est un avantage en matière de performances. Mais un même pixel du ''framebuffer'' est écrit plusieurs fois quand plusieurs quads se superposent, alors que le rendu inverse gère la situation avec une seule écriture (sauf si usage de la transparence). De plus, la gestion de la transparence était compliquée et les jeux devaient ruser en utilisation des solutions logicielles assez complexes. Niveau implémentation matérielle, une carte graphique en rendu direct demande juste trois circuits. Le premier est un circuit de calcul géométrique, qui rend la scène 3D. Le tri des quads est souvent réalisé par le processeur principal, et non pas par un circuit séparé. Toutes les étapes au-delà de l'étape de rastérisation étaient prises en charge par un VDC amélioré, qui écrivait des sprites/textures directement dans le ''framebuffer''. {|class="wikitable" |- ! Géométrie | Processeurs dédiés programmé pour émuler le pipeline graphique |- ! Tri des quads du plus lointain au plus proche | Processeur principal (implémentation logicielle) |- ! Application des textures | ''Blitter'' amélioré, capable de faire tourner et de zoomer sur des ''sprites''. |} L'implémentation était très simple et réutilisait des composants déjà existants : des VDC 2D pour l'application des textures, des processeurs dédiés pour la géométrie. Les unités de calcul de la géométrie étaient généralement implémentées avec un ou plusieurs processeurs dédiés. Vu qu'on savait déjà effectuer le rendu géométrique en logiciel, pas besoin de créer un circuit sur mesure. Il suffisait de dédier un processeur spécialisé rien que pour les calculs géométriques et on lui faisait exécuter un code déjà bien connu à la base. En clair, ils utilisaient un code spécifique pour émuler un circuit fixe. C'était clairement la solution la plus adaptée pour l'époque. Les unités géométriques étaient des processeurs RISC, normalement utilisés dans l'embarqué ou sur des serveurs. Elles utilisaient parfois des DSP. Pour rappel, les DSP des processeurs de traitement de signal assez communs, pas spécialement dédiés aux rendu 3D, mais spécialisé dans le traitement de signal audio, vidéo et autre. Ils avaient un jeu d'instruction assez proche de celui des cartes graphiques actuelles, et supportaient de nombreuses instructions utiles pour le rendu 3D. Le rendu direct a été utilisé dans la période de transition entre rendu 2D et rendu 3D, car il était très adapté pour faire cette transition. Coupler un VDC à un processeur pour la géométrie était particulièrement simple à l'époque. Le rendu direct a été utilisé sur des bornes d'arcade dès les années 90. Outre les bornes d'arcade, quelques consoles de 5ème génération utilisaient le rendu direct, avec les mêmes solutions matérielles. La géométrie était calculée sur plusieurs processeurs dédiés. Le reste du pipeline était géré par un VDC 2D qui implémentait le placage de textures. Deux consoles étaient dans ce cas : la 3DO, et la Sega Saturn. Le rendu direct est aujourd'hui abandonné. ===Le placage de textures inverse=== Le rendu précédent, le rendu direct, permet d'appliquer des textures directement dans le ''framebuffer''. Mais comme dit plus haut, il existe une seconde technique pour plaquer des textures, appelé le '''placage de texture inverse''', aussi appelé l'''UV Mapping''. Elle associe une texture complète pour un modèle 3D,contrairement au placage de tecture direct qui associe une texture par ''quad''/triangle. L'idée est que l'on attribue un texel à chaque sommet. Plus précisémment, chaque sommet est associé à des '''coordonnées de texture''', qui précisent quelle texture appliquer, mais aussi où se situe le texel à appliquer dans la texture. Par exemple, la coordonnée de texture peut dire : je veux le pixel qui est à ligne 5, colonne 27 dans cette texture. La correspondance entre texture et géométrie est réalisée lorsque les créateurs de jeu vidéo conçoivent le modèle de l'objet. [[File:Texture Mapping example.png|centre|vignette|upright=2|Exemple de placage de texture.]] Dans les faits, on n'utilise pas de coordonnées entières de ce type, mais deux nombres flottants compris entre 0 et 1. La coordonnée 0,0 correspond au texel en bas à gauche, celui de coordonnée 1,1 est tout en haut à droite. L'avantage est que ces coordonnées sont indépendantes de la résolution de la texture, ce qui aura des avantages pour certaines techniques de rendu, comme le ''mip-mapping''. Les deux coordonnées de texture sont notées u,v avec DirectX, ou encore s,t dans le cas général : u est la coordonnée horizontale, v la verticale. [[File:UVMapping.png|centre|vignette|upright=2|UV Mapping]] Avec le placage de texture inverse, la rastérisation se fait grosso-modo en trois étapes : la rastérisation proprement dite, le placage de textures, et les opérations finales qui écrivent un pixel dans le ''framebuffer''. Au niveau du matériel, ainsi que dans la plupart des API 3D, les trois étapes sont réalisées par des circuits séparés. [[File:01 3D-Rasterung-a.svg|vignette|Illustration du principe de la rasterization. La surface correspondant à l'écran est subdivisée en pixels carrés, de coordonnées x et y. La caméra est placée au point e. Pour chaque pixel, on trace une droite qui part de la caméra et qui passe par le pixel considéré. L'intersection entre une surface et cette droite se fait en un point, appartenant à un triangle.]] Lors de la rasterisation, chaque triangle se voit attribuer un ou plusieurs pixels à l'écran. Pour bien comprendre, imaginez une ligne droite qui part de caméra et qui passe par un pixel sur le plan de l'écran. Cette ligne intersecte 0, 1 ou plusieurs objets dans la scène 3D. Les triangles situés ces intersections entre cette ligne et les objets rencontrés seront associés au pixel correspondant. L'étape de rastérisation prend en entrée un triangle et renvoie la coordonnée x,y du pixel associé. Il s'agit là d'une simplification, car un triangle tend à occuper plusieurs pixels sur l'écran. L'étape de rastérisation fournit la liste de tous les pixels occupés par un triangle, et les traite un par un. Quand un triangle est rastérisé, le rasteriseur détermine la coordonnée x,y du premier pixel, applique une texture dessus, puis passe au suivant, et rebelote jusqu'à ce que tous les pixels occupés par le triangles aient été traités. L'implémentation matérielle du placage de texture inverse est beaucoup plus complexe que pour les autres techniques. Pour être franc, nous allons passer le reste du cours à parler de l'implémentation matérielle du placage de texture inverse, ce qui prendra plus d'une dizaine de chapitres. ==La transparence, les fragments et les ROPs== Dans ce qui suit, nous allons parler uniquement de la rastérisation avec placage de textures inverse. Les autres formes de rastérisation ne seront pas abordées. La raison est que tous les GPUs modernes utilisent cette forme de rastérisation, les exceptions étant rares. De même, ils utilisent un tampon de profondeur, pour l'élimination des surfaces cachées. La rastérisation effectue donc des calculs géométriques, suivis d'une étape de rastérisation, puis de placage des textures. Ces trois étapes sont réalisées par une unité géométrique, une unité de rastérisation, et un circuit de placage de textures. Du moins sur le principe, car les cartes graphiques modernes ont fortement optimisé l'implémentation et n'ont pas hésité à fusionner certains circuits. Mais nous verrons cela en temps voulu, nous n'allons pas résumer plusieurs décennies d'innovation technologique en quelques paragraphes. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Placage de textures |} Mais où mettre le tampon de profondeur ? Intuitivement, on se dit qu'il vaut mieux faire l'élimination des surfaces cachées le plus tôt possible, dès que la coordonnée de profondeur est connue. Et elle est connu à l'étape de rastérisation, une fois les sommets transformés. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Tampon de profondeur | Placage de textures |} En réalité, la profondeur des fragments est gérée par un circuit appelé le '''''Raster Operations Pipeline''''' (ROP), situé à la toute fin du pipeline graphique. Dans ce qui suit, nous utiliserons l'abréviation ROP pour simplifier les explications. Le ROP effectue quelques traitements sur les fragments, avant d'enregistrer l'image finale dans la mémoire vidéo. Il est placé à la fin du pipeline pour gérer correctement la transparence. Et nous allons voir pourquoi la transparence est gérée à la fin du pipeline. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Placage de textures | ''Raster Operations Pipeline'' |} ===Le mélange ''alpha''=== La transparence se manifeste quand plusieurs objets sont l'un derrière l'autre. Histoire de simplifier les explications, nous allons d'abord voir le cas où un objet semi-transparent est devant un objet opaque. La couleur perçue est alors un mélange de la couleur de l'objet opaque et celle de l'objet semi-transparent. Le mélange dépend d'à quel point l'objet semi-transparent est transparent. Avec un objet parfaitement transparent, seul l'objet opaque est visible. Avec un objet à moitié transparent, la couleur finale sera pour moitié celle de l'objet opaque, pour moitié celle de l'objet semi-transparent. Et c'est pareil pour les cas intermédiaires entre un objet totalement transparent et un objet totalement opaque. La transparence d'un objet/pixel est définie par un nombre, appelé la '''composante ''alpha'''''. Plus la composante alpha est élevée, plus le pixel est opaque. Elle vaut 0 pour un objet opaque et 1 pour un objet transparent. Elle est ajoutée aux composantes RGB, ce qui fait que tout fragment contient une "couleur de transparence" en plus des couleurs RGB. Elle agit comme un coefficient qui dit comment mélanger la couleur d'un objet transparent et d'un objet opaque. Le calcul de la transparence est une moyenne pondérée par la composante alpha. On parle alors d''''''alpha blending'''''. : <math>\text{Couleur finale} = \alpha \times \text{Couleur de l'objet transparent} + (1 - \alpha) \times \text{Couleur de l'objet opaque}</math> [[File:Texture splatting.png|centre|vignette|upright=2.0|Calcul de transparence. La première ligne montre le produit pour l'objet transparent, la seconde ligne est celle de l'objet opaque. La troisième ligne est celle de l'addition finale.]] Maintenant, qu'en est-il du cas où plusieurs objets sont superposés ? Si vous tracez une demi-droite dont l'origine est la caméra et qui passe par le pixel, il arrive qu'elle intersecte la géométrie en plusieurs points, un point par objet sur la ligne du regarde. Sans transparence, l'objet le plus proche cache tous les autres et c'est donc lui qui décide de la couleur du pixel. Mais avec un objet transparent, la couleur finale est un mélange de la couleur de plusieurs points d'intersection. Il faut donc calculer un pseudo-pixel pour chaque point d'intersection, auquel on donne le nom de '''fragment'''. Un fragment possède une position à l'écran, une coordonnée de profondeur, une couleur, ainsi que quelques autres informations potentiellement utiles. Les fragments attribués à un même pixel, qui sont à la même position sur l'écran, sont combinés pour obtenir la couleur finale de ce pixel. Il est possible d'utiliser le mélange ''alpha'' pour cela. Il suffit de faire le mélange ''alpha'' entre le fragment qui vient d'être calculé, et le pixel dans le ''framebuffer''. Pour cela, le fragment a une composante ''alpha'', qui est ajouté aux trois couleurs RGB. Le pixel déjà dans le ''framebuffer'' est un résultat temporaire, né du mélange ''alpha'' de tous les fragments précédents. Un défaut de cette méthode est qu'elle fonctionne assez mal avec un tampon de profondeur. Si le tampon de profondeur est activé, le mélange ''alpha'' ne fonctionne que si les objets sont rendus du plus lointain au plus proche. Et procéder dans cet ordre a un défaut : on dessine des objets dans le ''framebuffer'', pour qu'ensuite les objets devant écrasent ce qui a déjà été dessiné. Un même pixel peut donc être dessiné plusieurs fois, dont une seule sera pertinente. Et ces écritures utilisent de la bande passante mémoire, qui est une ressource précieuse sur un GPU moderne. Il s'agit d'un phénomène appelé '''''overdraw''''', ou sur-dessinage en français. Quelques optimisations permettent d'éliminer l'''overdraw'' en rendant les objets du plus proche au plus lointain, d'autres permettent de dessiner des objets dans un ordre arbitraire, mais nous ne pouvons pas en parler ici. Beaucoup de moteurs 3D rendent séparément les objets opaques et transparents. Une première passe rend les objets opaques, puis les objets transparents sont rendus dans une seconde passe. Les objets opaques sont rendus dans le désordre, ce qui fait qu'on n'a pas à les trier, alors que les objets transparents doivent être triés selon leur distance. un autre avantage est que le mélange ''alpha'' est désactivé lors de la première passe, alors que c'est la mise à jour du tampon de profondeur qui est désactivé lors de la seconde passe, ce qui augmente un peu les performances dans les deux cas. ===Le test ''alpha''=== Le test ''alpha'' est une technique qui permet d'annuler le rendu d'un fragment en fonction de sa transparence. Si la composante alpha est en-dessous ou au-dessus d'un seuil, le fragment est simplement abandonné. Le seuil en question est configurable, de même que la comparaison utilisée : on peut éliminer le fragment si sa transparence est au-dessus d'un certain seuil, en-dessous, égal, différent, etc. Il s'agit d'une optimisation qui est utile dans certains scénarios spécifiques. Par exemple, si l'objet a une transparence très élevée, du genre 95%, autant le compter comme complétement transparent, afin d'éviter des opérations de mélange ''alpha''. En effet, les opérations de mélange ''alpha'' sont très lentes, car elles demandent de faire des opérations de lecture-écriture en mémoire vidéo : on lit un pixel dans le ''framebuffer'', on applique le mélange ''alpha'' et on écrit le résultat en mémoire vidéo. L'''alpha test'' permet donc de gagner en performance au prix d'une baisse de la qualité d'image. Il y a cependant des cas où l'usage du test ''alpha'' est primordial, au-delà d'une question de performances. Un exemple classique est celui du rendu du feuillage dans un jeu 3D. Un feuillage est composé en assemblant plusieurs images de feuilles. Chaque feuille est un carré sur lequel on place une texture de feuille, qui est opaque pour la partie verte des feuilles, transparente pour le reste. Les carrés ne sont cependant pas superposés, mais s'intersectent fortement, ce qui fait que le mélange ''alpha'' ne donne pas de bons résultats. L'usage du test ''alpha'' permet d'obtenir un rendu correct. Pour d'informations via ce lien : * [https://bgolus.medium.com/anti-aliased-alpha-test-the-esoteric-alpha-to-coverage-8b177335ae4f Anti-aliased Alpha Test: The Esoteric Alpha To Coverage]. ===Les effets de brouillard=== Les '''effets de brouillard''' sont nécessaires dans certains jeux vidéo pour l'ambiance (pensez à des jeux d'horreur comme Silent Hill), mais ils ont surtout été utilisés pour économiser des calculs. L'idée est de ne pas calculer les graphismes au-delà d'une certaine distance, sans que cela se voie. Le ''view frustum'' utilise alors un plan limite, au-delà duquel on ne voit pas les objets. Mais ce plan limite donne une cassure inesthétique dans le rendu. Pour masquer cette cassure, les programmeurs ajoutaient un effet de brouillard. Les objets au-delà du plan limite étaient totalement dans le brouillard, puis ce brouillard se réduisait progressivement en se rapprochant de la caméra, avant de s'annuler à partir d'une certaine distance. Pour calculer le brouillard, on effectue un mélange ''alpha'' entre la couleur du pixel et une ''couleur de brouillard''. La différence est que l'on n'utilise pas la transparence pour faire le mélange, mais un '''coefficient de brouillard''', noté <math>\text{fog}(z)</math>. : <math>\text{Couleur finale} = \text{fog}(z) \times \text{Couleur de brouillard} + [ 1 - \text{fog}(z) ] \times \text{Couleur du pixel}</math> Le coefficient de brouillard dépend de la coordonnée de profondeur, de la distance du pixel par rapport à la caméra. Le brouillard démarre à une distance <math>z_{fog-start}</math>, et masque totalement les objets à partir d'une distance <math>z_{fog-end}</math>. Entre les deux, le coefficient de brouillard dépend de la distance. OpenGL autorise trois formules de calcul suivantes : : <math>\text{fog}(z) = \frac{z_{fog-end} - z}{z_{fog-end} - z_{fog-start}}</math> : <math>\text{fog}(z) = e^{- k \times z}</math> : <math>\text{fog}(z) = e^{- (k \times z)^2}</math> ==L'éclairage d'une scène 3D== L'éclairage d'une scène 3D calcule les ombres, mais aussi la luminosité de chaque pixel, ainsi que bien d'autres effets graphiques. Les algorithmes d'éclairage ont longtemps été implémentés directement en matériel, les cartes graphiques géraient l'éclairage dans des circuits spécialisés. Aussi, il est important de voir ces algorithmes d'éclairage. Il est possible d'implémenter l'éclairage à deux endroits différents du pipeline : juste avant la rastérisation, et après la rastérisation. ===Les sources de lumière et les couleurs associées=== L'éclairage d'une scène 3D provient de sources de lumières, comme des lampes, des torches, le soleil, etc. Il existe de nombreux types de sources de lumière, et nous n'allons parler que des principales. Elles sont au nombre de quatre et elles sont illustrées ci-dessous. [[File:3udUJ.gif|centre|vignette|upright=2|Types de sources de lumière.]] [[File:Graphics lightmodel directional.png|vignette|upright=1.0|Source de lumière directionnelle.]] Les '''sources directionnelles''' servent à modéliser des sources de lumière très éloignées, comme le soleil ou la lune. Elles sont simplement définies par un vecteur qui indique la direction de la lumière, rien de plus. Les '''sources ponctuelles''' sont des points, qui émettent de la lumière dans toutes les directions. Elles sont définies par une position, et une intensité lumineuse, éventuellement la couleur de la lumière émise. Il existe deyux types de sources de lumière ponctuelles. * Le premières émettent de manière égale dans toutes les directions. Elles sont appelées des ''point light'' dans le schéma du dessus. * Les secondes émettent de la lumière dans une '''direction privilégiée'''. L'exemple le plus parlant est celui d'une lampe-torche : elle émet de la lumière "tout droit", dans la direction où la lampe est orientée. Elles sont appelées des ''sport light'' dans le schéma du dessus. La direction privilégiée est un vecteur, notée v dans le schéma du dessous. [[File:Graphics lightmodel ambient.png|vignette|upright=1.0|Lumière ambiante.]] En théorie, la lumière rebondit sur les surfaces et a tendance à se disperser un peu partout à force de rebondir. C'est ce qui explique qu'on arrive à voir à l'intérieur d'une pièce si une fenêtre est ouverte. Il en résulte un certain '''éclairage ambiant''', qui est assez difficile à représenter dans un moteur de rendu 3D. Auparavant, l'éclairage ambiant était simulé par une lumière égale en tout point de la scène 3D, appelée simplement la '''lumière ambiante'''. Précisément, on suppose que la lumière ambiante en un point vient de toutes les directions et a une intensité constante, identique dans toutes les directions. Le tout est illustré ci-contre. C'est assez irréaliste, mais ça donne une bonne approximation de la lumière ambiante. ===La lumière incidente : le terme géométrique=== Pour simplifier, nous allons supposer que l'éclairage est calculé pour chaque sommet, pas par triangle. C'est de loin le cas le plus courant, aussi ce n'est pas une simplification abusive. La lumière qui arrive sur un sommet est appelée la '''lumière incidente'''. La couleur d'un sommet dépend de deux choses : la lumière incidente directe, comment il réfléchit cette lumière. Mathématiquement, il est possible de résumer cela avec le produit de deux termes : l'intensité de la lumière incidente, une fonction qui indique comment la surface réfléchit la lumière incidente. La fonction en question est appelée la '''réflectivité bidirectionnelle'''. Le terme anglais est ''bidirectional reflectance distribution function'', abrévié en BRDF, et nous utiliserons cette abréviation dans ce qui suit. : <math>\text{Couleur finale} = \text{Lumière incidente} \times BRDF(...)</math> La lumière incidente vient soit directement des sources de lumière, soit de la lumière qui a rebondit sur d'autres objets proches. La première est appelée la lumière directe, celle qui vient des rebonds s'appelle la lumière indirecte. Pour simplifier, la lumière indirecte est gérée par la lumière ambiante, nous passons sous silence les techniques d'illumination globale. En clair : nous allons nous limiter au cas où la lumière incidente vient directement d'une source de lumière, pas d'un rebond. Intuitivement, la lumière incidente est simplement égale à l'intensité de la source de lumière. Sauf que ce n'est qu'une approximation, et une assez mauvaise. En réalité, l'approximation est bonne si la lumière arrive proche de la verticale, mais elle est d'autant plus mauvaise que la lumière arrive penchée, voire rasante. La raison : la lumière incidente sera étalée sur une surface plus grande, si elle arrive penchée. Si vous vous souvenez de vos cours de collège, c'est le même principe qui explique les saisons. La lumière du soleil est proche de la verticale en été, mais est de plus en plus penché quand on s'avance vers l'Hiver. La lumière solaire est donc étalée sur une surface plus grande, ce qui fait qu'un point de la surface recevra moins de lumière, celle-ci étant diluée, étalée. [[File:Radiación solar.png|centre|vignette|upright=2|Exemple avec la lumière solaire.]] [[File:Angle of incidence.svg|vignette|upright=1|Angle d'incidence.]] En clair, tout dépend de l''''angle d'incidence''' de la lumière. Reste à voir comment calculer cet angle. La lumière incidente est définie par un vecteur, qui part de la source de lumière et atterrit sur le sommet considéré. Imaginez simplement que ce vecteur suit un rayon lumineux provenant de la source de lumière. Le vecteur pour la lumière incidente sera noté L. L'angle d'incidence est l'angle que fait ce vecteur avec la verticale de la surface, au niveau du sommet considéré. [[File:Graphics lightmodel ptsource.png|vignette|Normale de la surface.]] Pour cela, les calculs d'éclairage ont besoin de connaitre la verticale d'un sommet. Un sommet est donc associé à un vecteur, appelé la '''normale''', qui indique la verticale en ce point. Deux sommets différents peuvent avoir deux normales différentes, même s'ils sont proches. Elles sont d'autant plus différentes que la surface est rugueuse, non-lisse. La normale est prédéterminée lors de la création du modèle 3D, il n'y a pas besoin de le calculer. Par contre, elle est modifiée lors de l'étape de transformation, quand on place le modèle 3D dans la scène 3D. Les deux autres vecteurs sont à calculer à chaque image, car ils changent quand on bouge le sommet. La lumière qui arrive sur la surface dépend de l'angle entre la normale et le vecteur L. Précisément, elle dépend du cosinus de cet angle. En multipliant ce cosinus avec l'intensité de la lumière, on a la lumière arrivante. La couleur finale d'un pixel est donc : : <math>\text{Couleur finale} = I \times \cos{(N, L)} \times BRDF(...)</math> Le terme <math>I \times \cos{N, L}</math> ne dépend pas de la surface considérée. Juste de la position de la source de lumière, de la position du sommet et de son orientation par rapport à la lumière. Aussi, il est parfois appelé le '''terme géométrique''', en opposition aux propriétés de la surface. Les propriétés de la surface sont définies par un '''''material''''', qui indique comment il réfléchit la lumière, ainsi que sa texture. ===Le produit scalaire de deux vecteurs=== Calculer le terme géométrique demande de calculer le cosinus d'un angle. Et il n'est pas le seul : les autres calculs d'éclairage que nous allons voir demandent de calculer des cosinus. Or, les calculs trigonométriques sont très gourmands pour le GPU. Pour éviter le calcul d'un cosinus, les GPU utilisent une opération mathématique appelée le ''produit scalaire''. Le produit scalaire agit sur deux vecteurs, que l'on notera A et B. Un produit scalaire prend : la longueur des deux vecteurs, et l'angle entre les deux vecteurs noté <math>\omega</math>. Le produit scalaire est équivalent à la formule suivante : : <math>\text{Produit scalaire de deux vecteurs A et B} = \vec{A} \cdot \vec{B} = A \times B \times \cos{(\omega)}</math>, avec A et B la longueur des deux vecteurs A et B. L'avantage est que le produit scalaire se calcule simplement avec des additions, soustractions et multiplications, des opérations que les cartes graphiques savent faire très facilement. Le produit scalaire de deux vecteurs de coordonnées x,y,z est le suivant : : <math>\vec{A} \cdot \vec{B} = x_A \times x_B + y_A \times y_B + z_A \times z_B</math> En clair, on multiplie les coordonnées identiques, et on additionne les résultats. Rien de compliqué. Un avantage est que tous les vecteurs vus précédemment sont normalisés, à savoir qu'ils ont une longueur qui vaut 1. Ainsi, le calcul du produit scalaire devient équivalent au calcul du produit scalaire. ===La réflexion de la lumière sur la surface=== [[File:Ray Diagram 2.svg|vignette|Reflection de la lumière sur une surface parfaitement lisse.]] Maintenant que nous venons de voir le terme géométrique, voyons le BRDF, qui définit comment la surface de l'objet 3D réfléchit la lumière. Vos cours de collège vous ont sans doute appris que la lumière est réfléchie avec le même angle d'arrivée. L'angle d'incidence et l'angle de réflexion sont égaux, comme illustré ci-contre. On parle alors de '''réflexion parfaite'''. Mais cela ne vaut que pour une surface parfaitement lisse, comme un miroir parfait. Dans la réalité, une surface a tendance à renvoyer des rayons dans toutes les directions. La raison est qu'une surface réelle est rugueuse, avec de petites aspérités et des micro-reliefs, qui renvoient la lumière dans des directions "aléatoires". La lumière « rebondit » sur la surface de l'objet et une partie s'éparpille dans un peu toutes les directions. On parle alors de '''réflexion diffuse'''. {| |- |[[File:Dioptre reflexion diffuse speculaire refraction.svg|vignette|upright=1.4|Différence entre réflexion diffuse et spéculaire.]] |[[File:Diffuse reflection.svg|vignette|upright=1|Réflexion diffuse.]] |} Maintenant, imaginons que la surface n'ait qu'une réflexion diffuse, pas d'autres formes de réflexion. Et imaginons aussi que cette réflexion diffuse soit parfaite, à savoir que la lumière réfléchie soit renvoyée à l'identique dans toutes les directions, sans aucune direction privilégiée. On a alors le ''material'' le plus simple qui soit, appelé un '''''diffuse material'''''. Vu que la lumière est réfléchie à l'identique dans toutes les directions, elle sera identique peu importe où on place la caméra. La lumière finale ne dépend donc que des propriété de la surface, que de sa couleur. En clair, il suffit de donner une '''couleur diffuse''' à chaque sommet. La couleur diffuse est simplement multipliée par le terme géométrique, pour obtenir la lumière réfléchie finale. Rien de plus, rien de moins. Cela donne l'équation suivante, avec les termes suivants : * L est le vecteur pour la lumière incidente ; * N est la normale du sommet ; * I est l'intensité de la source de lumière ; * <math>C_d</math> est la couleur diffuse. : <math>\text{Illumination diffuse} = C_d \times \left[ I \times (\vec{N} \cdot \vec{L}) \right]</math> Rajoutons maintenant l'effet de la lumière ambiante à un ''material'' de ce genre. Pour rappel, la lumière ambiante vient de toutes les directions à part égale, ce qui fait que son angle d'incidence n'a donc pas d'effet. L'intensité de la lumière ambiante est déterminée lors de la création de la scène 3D, c'est une constante qui n'a pas à être calculée. Pour obtenir l'effet de la lumière ambiante sur un objet, il suffit de multiplier sa couleur diffuse par l'intensité de la lumière ambiante. Cependant, de nombreux moteurs de jeux ajoutent une '''couleur ambiante''', différente de la couleur diffuse. : <math>\text{Illumination ambiante} = C_a \times I_a</math> avec <math>C_a</math> la couleur ambiante du point de surface et <math>I_a</math> l'intensité de la lumière ambiante. En plus de la réflexion diffuse parfaite, de nombreux matériaux ajoutent une '''réflexion spéculaire''', qui n'est pas exactement la réflexion parfaite, en est très proche. Les rayons réfléchis sont très proches de la direction de réflexion parfaite, et s'atténuent très vite en s'en éloignant. Le résultat ressemble à une sorte de petit "point blanc", très lumineux, orienté vers la source de lumière, appelé le '''''specular highlight'''''. La réflexion diffuse est prédominante pour les matériaux rugueux, alors que la réflexion spéculaire est dominante sur les matériaux métalliques ou très lisses. [[File:Phong components version 4.png|centre|vignette|upright=3.0|Couleurs utilisées dans l'algorithme de Phong.]] [[File:Phong Vectors.svg|vignette|Vecteurs utilisés dans l'algorithme de Phong (et dans le calcul de l'éclairage, de manière générale).]] Pour calculer la réflexion spéculaire, il faut d'abord connaitre le vecteur pour la réflexion parfaite, que nous noterons R dans ce qui suit. Le vecteur R peut se calculer avec la formule ci-dessous : : <math>\vec{R} = 2 (\vec{L} \cdot \vec{N}) \times \vec{N} - \vec{L} </math> La réflexion spéculaire dépend de l'angle entre la direction du regard et la normale : plus celui-ci est proche de l'angle de réflexion parfaite, plus la réflexion spéculaire sera intense. Le vecteur pour la direction du regard sera noté V, pour vue ou vision. La réflexion spéculaire est une fonction qui dépend de l'angle entre les vecteurs R et V. Le calcul de la réflexion spéculaire utilise une '''couleur spéculaire''', qui est l'équivalent de la couleur diffuse pour la réflexion spéculaire. : <math>\text{BRDF spéculaire} = C_s \times f(\vec{R} \cdot \vec{V}) </math> La fonction varie grandement d'un modèle de calcul spéculaire à l'autre. Aussi, je ne rentre pas dans le détail. L'essentiel est que vous compreniez que le calcul de l'éclairage utilise de nombreux calculs géométriques, réalisés avec des produits scalaires. Les calculs géométriques utilisent la couleur d'un sommet, la normale du sommet, et le vecteur de la lumière incidente. Les autres informations sont calculées à l'exécution. ===Les algorithmes d'éclairage basiques : par triangle, par sommet et par pixel=== Dans tout ce qui a été dit précédemment, l'éclairage est calculé pour chaque sommet, ce qui fait qu'on parle d''''éclairage par sommet''', ou ''vertex lighting''. Il attribue une illumination/couleur à chaque sommet de la scène 3D. Mais une fois qu'on a obtenu la couleur des sommets, reste à colorier les triangles. Pour obtenir la couleur finale d'un pixel, l'éclairage par sommet fait une moyenne de la couleur de chaque sommet. La moyenne est une moyenne pondérée, qui tient compte de la distance du sommet avec le pixel. Plus le pixel est loin d'un sommet, plus son coefficient est petit. Typiquement, le coefficient varie entre 0 et 1 : de 1 si le pixel est sur le sommet, à 0 si le pixel est sur un des sommets adjacents. La moyenne effectuée est généralement une interpolation bilinéaire, qui est prise en charge par l'étape de rastérisation. L'éclairage par sommet a eu son heure de gloire, mais il est maintenant remplacé par l''''éclairage par pixel''' (''per-pixel lighting''), qui calcule l'éclairage pixel par pixel. En clair, l’éclairage est finalisé après l'étape de rastérisation, il ne se fait pas qu'au niveau de la géométrie. Il existe plusieurs types d'éclairage par pixel, mais on peut les classer en deux grands types : l'éclairage de Phong et le ''bump/normal mapping''. L''''éclairage de Phong''' calcule l'éclairage pixel par pixel. Avec cet algorithme, la géométrie n'est pas éclairée : les couleurs des sommets ne sont pas calculées. A la place, les normales sont envoyées à l'étape de rastérisation, qui effectue une opération d'interpolation, qui renvoie une normale pour chaque pixel. Les calculs d'éclairage utilisent alors ces normales pour faire les calculs d'éclairage pour chaque pixel. {| |- |[[File:Per face lighting.png|vignette|upright=1|Eclairage par triangle.]] |[[File:Per vertex lighting.png|vignette|upright=1|Eclairage par sommet.]] |[[File:Per fragment lighting.png|vignette|upright=1|Eclairage de Phong.]] |- |[[File:Per face lighting example.png|vignette|upright=1|Eclairage par triangle.]] |[[File:Per vertex lighting example.png|vignette|upright=1|Eclairage par sommet.]] |[[File:Per fragment lighting example.png|vignette|upright=1|Eclairage de Phong.]] |} La technique du '''''normal mapping''''' est assez simple à expliquer, sans compter que plusieurs cartes graphiques l'ont implémentée directement dans leurs circuits. Là où l'éclairage de Phong interpole les normales pour chaque pixel, le ''normal-mapping'' précalcule les normales d'une surface dans une texture, appelée la ''normal-map''. Lors du rendu 3D, la carte graphique lit les normales adéquates directement depuis cette texture, puis fait les calculs d'éclairage avec. Le ''normal mapping'' permettent d'ajouter du relief et des détails sur des surfaces planes en jouant sur l'éclairage. Il permet de simplifier grandement la géométrie rendue, tout en utilisant l'éclairage pour compenser. [[File:WallSimpleAndNormalMapping.png|centre|vignette|upright=2|Différence sans et avec ''normal-mapping''.]] ===Les ''shaders'' : des programmes exécutés sur le GPU=== Maintenant que nous venons de voir les algorithmes d'éclairages, il est temps de voir comment les réaliser sur une carte graphique. Nous venons de voir qu'il y a une différence entre l'éclairage par pixel et par sommet. Intuitivement, l'éclairage par sommet devrait se faire avec les calculs géométriques, alors que l'éclairage par pixel devrait se faire après avoir appliqué les textures. Les toutes premières cartes graphiques ne géraient ni l'éclairage par sommet, ni l'éclairage par pixel. Elles laissaient les calculs géométriques au CPU. Par la suite, la Geforce 256 a intégré '''circuit de ''Transform & Lightning''''', qui s'occupait de tous les calculs géométriques, éclairage par sommet inclus (d'où le L de T&L). Elle gérait alors l'éclairage par sommet, mais un algorithme particulier, qui n'était pas très flexible. Il ne gérait que des ''material'' bien précis (des ''Phong materials''), rien de plus. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Unité de T&L : géométrie | Rastérisation | Placage de textures | ''Raster Operations Pipeline'' |} L'amélioration suivante est venue sur la Geforce 3 : l'unité de T&L est devenue programmable. Au vu le grand nombre d'algorithmes d'éclairages possibles et le grand nombre de ''materials'' possibles, c'était la seule voie possibles. Les programmeurs pouvaient programmer leurs propres algorithmes d'éclairage par sommet, même s'ils devaient aussi programmer les étapes de transformation et de projection. Mais nous détaillerons cela dans un chapitre dédié sur l'historique des GPUs. Ce qui est important est que la Geforce 3 a introduit une fonctionnalité absolument cruciale pour le rendu 3D moderne : les '''''shaders'''''. Il s'agit de programmes informatiques exécutés par la carte graphique, qui servaient initialement à coder des algorithmes d'éclairage. D'où leur nom : ''shader'' pour ''shading'' (éclairage en anglais). Cependant, l'usage modernes des shaders dépasse le cadre des algorithmes d'éclairage. L'avantage est que cela simplifie grandement l'implémentation des algorithmes d'éclairage. Pas besoin de les intégrer dans la carte graphique pour les utiliser, pas besoin d'un circuit distinct pour chaque algorithme. Sans shaders, si la carte graphique ne gère pas un algorithme d'éclairage, on ne peut pas l'utiliser. A la rigueur, il est parfois possible de l'émuler avec des contournements logiciels, mais au prix de performances souvent désastreuses. Avec des shaders, il est possible de programmer l'algorithme d'éclairage de notre choix, pour l'exécuter sur la carte graphique, avec des performances plus que convenables. [[File:Implémentation de l'éclairage sur les cartes graphiques.png|vignette|Implémentation de l'éclairage sur les cartes graphiques]] Il existe plusieurs types de shaders, mais les deux principaux sont les '''''vertex shaders''''' et les '''''pixel shaders'''''. Les pixels shaders s'occupent de l'éclairage par pixel, leur nom est assez parlent. Les vertex shaders s'occupent de l'éclairage par sommet, mais aussi des étapes de transformation/projection. Je parle bien des trois étapes de transformation vues plus haut, qui effectuent des calculs de transformation de coordonnées avec des matrices. La raison à cela est que les calculs de transformation ressemblent beaucoup aux calculs d'éclairage par sommet. Ils impliquent tous deux des calculs vectoriels, comme des produits scalaires et des produits vectoriels, qui agissent sur des sommets/triangles. Si la carte graphique incorpore un processeur de shader capable de faire de tels calculs, alors il peut servir pour les deux. Pour implémenter les shaders, il a fallu ajouter des processeurs à la carte graphique. Les processeurs en question exécutent les shaders, ils peuvent lire ou écrire dans des textures, mais ne font rien d'autres. Les ''vertex shaders'' font tout ce qui a trait à la géométrie, ils remplacent l'unité de T&L. Les pixels shaders sont entre la rastérisation et les ROPs, ils sont très liés à l'unité de texture. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | rowspan="2" class="f_rouge" | ''Vertex shader'' | rowspan="2" | Rastérisation | Placage de textures | rowspan="2" |''Raster Operations Pipeline'' |- | class="f_rouge" | ''Pixel shader'' |} <noinclude> {{NavChapitre | book=Les cartes graphiques | prev=Les cartes d'affichage des anciens PC | prevText=Les cartes d'affichage des anciens PC | next=Avant les GPUs : les cartes accélératrices 3D | nextText=Avant les GPUs : les cartes accélératrices 3D }}{{autocat}} </noinclude> 5tiv3dl2lgn9f89snjgn5oagf52hdvz 765164 765150 2026-04-26T20:28:10Z Mewtow 31375 /* La différence entre rastérisation et lancer de rayons */ 765164 wikitext text/x-wiki Le premier jeu à utiliser de la "vraie 3D" texturée fut le jeu Quake, premier du nom. Et depuis sa sortie, la grande majorité des jeux vidéo utilisent de la 3D, même s'il existe encore quelques jeux en 2D. Face à la prolifération des jeux vidéo en 3D, les fabricants de cartes graphiques ont inventé les cartes accélératrices 3D, des cartes vidéo capables d'accélérer le rendu en 3D. Dans ce chapitre, nous allons voir comment elles fonctionnent et comment elles ont évolué dans le temps. Pour comprendre comment celles-ci fonctionnent, il faut faire quelques rapides rappels sur les bases du rendu 3D. ==Les bases du rendu 3D== Une '''scène 3D''' est composée d'un espace en trois dimensions, dans laquelle le moteur d’un jeu vidéo place des objets et les fait bouger. Cette scène est, en première approche, un simple parallélogramme. Un des coins de ce parallélogramme sert d’origine à un système de coordonnées : il est à la position (0, 0, 0), et les axes partent de ce point en suivant les arêtes. Les objets seront placés à des coordonnées bien précises dans ce parallélogramme. ===Les objets 3D et leur géométrie=== <noinclude>[[File:Dolphin triangle mesh.png|vignette|Illustration d'un dauphin, représenté avec des triangles.]]</noinclude> Dans la quasi-totalité des jeux vidéo actuels, les objets et la scène 3D sont modélisés par un assemblage de triangles collés les uns aux autres, ce qui porte le nom de '''maillage''', (''mesh'' en anglais). Il a été tenté dans le passé d'utiliser des quadrilatères (rendu dit en ''quad'') ou d'autres polygones, mais les contraintes techniques ont fait que ces solutions n'ont pas été retenues. [[File:CG WIKI.jpg|centre|vignette|upright=2|Exemple de modèle 3D.]] Les modèles 3D sont définis par leurs sommets, aussi appelés '''vertices''' dans le domaine du rendu 3D. Chaque sommet possède trois coordonnées, qui indiquent sa position dans la scène 3D : abscisse, ordonnée, profondeur. Les sommets sont regroupés en triangles, qui sont formés en combinant trois sommets entre eux. Les anciennes cartes graphiques géraient aussi d'autres formes géométriques, comme des points, des lignes, ou des quadrilatères. Les quadrilatères étaient appelés des ''quads'', et ce terme reviendra occasionnellement dans ce cours. De telles formes basiques, gérées nativement, sont appelées des '''primitives'''. La représentation exacte d'un objet est donc une liste plus ou moins structurée de sommets. La liste doit préciser les coordonnées de chaque sommet, ainsi que comment les relier pour former des triangles. Pour cela, l'objet est représenté par une structure qui contient la liste des sommets, mais aussi de quoi savoir quels sont les sommets reliés entre eux par un segment. Nous en dirons plus dans le chapitre sur le rendu de la géométrie. ===La caméra : le point de vue depuis l'écran=== Outre les objets proprement dit, on trouve une '''caméra''', qui représente les yeux du joueur. Cette caméra est définie au minimum par : * une position ; * par la direction du regard (un vecteur). A la caméra, il faut ajouter tout ce qui permet de déterminer le '''champ de vision'''. Le champ de vision contient tout ce qui est visible à l'écran. Et sa forme dépend de la perspective utilisée. Dans le cas le plus courant dans les jeux vidéos en 3D, il correspond à une '''pyramide de vision''' dont la pointe est la caméra, et dont les faces sont délimitées par les bords de l'écran. A l'intérieur de la pyramide, il y a un rectangle qui représente l'écran du joueur, appelé le '''''viewport'''''. <noinclude>[[File:ViewFrustum.jpg|centre|vignette|upright=2|Caméra.]]</noinclude> [[File:ViewFrustum.svg|vignette|upright=1|Volume délimité par la caméra (''view frustum'').]] La majorité des jeux vidéos ajoutent deux plans : * un ''near plane'' en-deça duquel les objets ne sont pas affichés. Il élimine du champ de vision les objets trop proches. * Un ''far plane'', un '''plan limite''' au-delà duquel on ne voit plus les objets. Il élimine les objets trop lointains. Avec ces deux plans, le champ de vision de la caméra est donc un volume en forme de pyramide tronquée, appelé le '''''view frustum'''''. Le tout est parfois appelée, bien que par abus de langage, la pyramide de vision. Avec d'autres perspectives moins utilisées, le ''view frustum'' est un pavé, mais nous n'en parlerons pas plus dans le cadre de ce cours car elles ne sont presque pas utilisés dans les jeux vidéos actuels. ===Les textures=== Tout objet à rendre en 3D est donc composé d'un assemblage de triangles, et ceux-ci sont éclairés et coloriés par divers algorithmes. Pour rajouter de la couleur, les objets sont recouverts par des '''textures''', des images qui servent de papier peint à un objet. Un objet géométrique est donc recouvert par une ou plusieurs textures qui permettent de le colorier ou de lui appliquer du relief. [[File:Texture+Mapping.jpg|centre|vignette|upright=2|Texture Mapping]] Notons que les textures sont des images comme les autres, codées pixel par pixel. Pour faire la différence entre les pixels de l'écran et les pixels d'une texture, on appelle ces derniers des '''texels'''. Ce terme est assez important, aussi profitez-en pour le mémoriser, nous le réutiliserons dans quelques chapitres. Un autre point lié au fait que les textures sont des images est leur compression, leur format. N'allez pas croire que les textures sont stockées dans un fichier .jpg, .png ou tout autre format de ce genre. Les textures utilisent des formats spécialisés, comme le DXTC1, le S3TC ou d'autres, plus adaptés à leur rôle de texture. Mais qu'il s'agisse d'images normales (.jpg, .png ou autres) ou de textures, toutes sont compressées. Les textures sont compressées pour prendre moins de mémoire. Songez que la compression de texture est terriblement efficace, souvent capable de diviser par 6 la mémoire occupée par une texture. S'en est au point où les textures restent compressées sur le disque dur, mais aussi dans la mémoire vidéo ! Nous en reparlerons dans le chapitre sur la mémoire d'une carte graphique. Plaquer une texture sur un objet peut se faire de deux manières, qui portent les noms de placage de texture inverse et direct. Le placage de texture direct a été utilisé au tout début de la 3D, sur des bornes d'arcade et les consoles de jeu 3DO, PS1, Sega Saturn. De nos jours, on utilise uniquement la technique de placage de texture inverse. Les deux seront décrites dans le détail plus bas. ===La différence entre rastérisation et lancer de rayons=== Les techniques de rendu 3D sont nombreuses, mais on peut les classer en deux grands types : le ''lancer de rayons'' et la ''rasterization''. Sans décrire les deux techniques, sachez cependant que le lancer de rayon n'est pas beaucoup utilisé pour les jeux vidéo. Il est surtout utilisé dans la production de films d'animation, d'effets spéciaux, ou d'autres rendu spéciaux. Dans les jeux vidéos, il est surtout utilisé pour quelques effets graphiques, la rasterization restant le mode de rendu principal. La raison principale est que le lancer de rayons demande beaucoup de puissance de calcul. Une autre raison est que créer des cartes accélératrices pour le lancer de rayons n'est pas simple. Il a existé des cartes accélératrices permettant d'accélérer le rendu en lancer de rayons, mais elles sont restées confidentielles. Les cartes graphiques modernes incorporent quelques circuits pour accélérer le lancer de rayons, mais ils restent d'un usage marginal et servent de compléments au rendu par rastérization. Un chapitre entier sera dédié aux cartes accélératrices de lancer de rayons et nous verrons pourquoi le lancer de rayons est difficile à implémenter avec des performances convenables, ce qui explique que les jeux vidéo utilisent la ''rasterization''. La rastérisation est structurée autour de trois étapes principales : * Une étape purement logicielle, effectuée par le processeur, où le moteur physique calcule la géométrie de la scène 3D. * Une étape de '''traitement de la géométrie''', qui gère tout ce qui a trait aux sommets et triangles. * Une étape de '''rastérisation''' qui détermine sur quels pixels de l'écran est affiché le triangle. * Une étape de '''traitement des pixels''', qui colorie les pixels et gère les textures. Il existe plusieurs rendus différents et la rastérisation ne se fait pas de la même manière selon le rendu utilisé. Il existe des rendus sans textures, d'autres avec, d'autres avec éclairage, d'autres sans, etc. Par contre, l'étape de calcul de la géométrie est la même quel que soit le rendu ! Mieux : le calcul de la géométrie se fait de la même manière entre rastérisation et lancer de rayons, il est le même quelle que soit la technique de rendu 3D utilisée. Les trois étapes précédentes sont réalisées dans des circuits ou processeurs séparés, comme on le verra plus tard. Et cela permet d'utiliser la technique dite du '''pipeline'''. Concrètement, supposons que la carte graphique traite les données par paquets de triangles (en réalité, c'est des paquets de sommets, mais passons). L'étape de traitement de la géométrie peut travailler sur un paquet de triangle, pendant que le paquet précédent est dans l'étape de rastérisation, et que le paquet encore précédent est en train de traiter ses pixels. Cela permet de traiter trois paquets de triangles en même temps, mais à des états d'avancements différents. Mieux que cela : le traitement de la géométrie est lui-même composé d'une succession de sous-étapes, la rasterisation est elle-même découpée en plusieurs sous-étapes, et ainsi de suite. Le nombre d'étapes pour une carte graphique moderne dépasse la dizaine. La rastérisation calcule un rendu 3D avec une suite d'étapes consécutives qui doivent s'enchainer dans un ordre bien précis. L'ensemble de ces étapes est appelé le '''pipeline graphique''', qui sera détaillé dans ce qui suit. ==Le calcul de la géométrie== Le calcul de la géométrie regroupe plusieurs manipulations différentes. La principale demande juste de placer les modèles 3D dans la scène, de placer les objets dans le monde. Puis, il faut centrer la scène 3D sur la caméra. Les deux changements ont pour point commun de demander des changements de repères. Par changement de repères, on veut dire que l'on passe d'un système de coordonnées à un autre. En tout, il existe trois changements de repères distincts qui sont regroupés dans l''''étape de transformation''' : un premier qui place chaque objet 3D dans la scène 3D, un autre qui centre la scène du point de vue de la caméra, et un autre qui corrige la perspective. ===Les trois étapes de transformation=== La première étape place les objets 3D dans la scène 3D. Un modèle 3D est représentée par un ensemble de sommets, qui sont reliés pour former sa surface. Les données du modèle 3D indiquent, pour chaque sommet, sa position par rapport au centre de l'objet qui a les coordonnées (0, 0, 0). La première étape place l'objet 3D à une position dans la scène 3D, déterminée par le moteur physique, qui a des coordonnées (X, Y, Z). Une fois placé dans la scène 3D, le centre de l'objet passe donc des coordonnées (0, 0, 0) aux coordonnées (X, Y, Z) et tous les sommets de l'objet doivent être mis à jour. De plus, l'objet a une certaine orientation : il faut aussi le faire tourner. Enfin, l'objet peut aussi subir une mise à l'échelle : on peut le gonfler ou le faire rapetisser, du moment que cela ne modifie pas sa forme, mais simplement sa taille. En clair, le modèle 3D subit une translation, une rotation et une mise à l'échelle, les trois impliquant une modification des coordonnées des sommets.. [[File:Similarity and congruence transformations.svg|centre|vignette|upright=1.5|Transformations géométriques possibles pour chaque triangle.]] Une fois le placement des différents objets effectué, la carte graphique effectue un changement de coordonnées pour centrer le monde sur la caméra. Au lieu de considérer un des bords de la scène 3D comme étant le point de coordonnées (0, 0, 0), il va passer dans le référentiel de la caméra. Après cette transformation, le point de coordonnées (0, 0, 0) sera la caméra. La direction de la vue du joueur sera alignée avec l'axe de la profondeur (l'axe Z). [[File:View transform.svg|centre|vignette|upright=2|Étape de transformation dans un environnement en deux dimensions : avant et après. On voit que l'on centre le monde sur la position de la caméra et dans sa direction.]] Enfin, il faut aussi corriger la perspective, ce qui est le fait de l'étape de projection, qui modifie la forme du ''view frustum'' sans en modifier le contenu. Différents types de perspective existent et celles-ci ont un impact différent les unes des autres sur le ''view frustum''. Dans le cas qui nous intéresse, le ''view frustum'' passe d’une forme de trapèze tridimensionnel à une forme de pavé dont l'écran est une des faces. ===Les changements de coordonnées se font via des multiplications de matrices=== Les trois étapes précédentes demande de faire des changements de coordonnées, chaque sommet voyant ses coordonnées remplacées par de nouvelles. Or, un changement de coordonnée s'effectue assez simplement, avec des matrices, à savoir des tableaux organisés en lignes et en colonnes avec un nombre dans chaque case. Un changement de coordonnées se fait simplement en multipliant le vecteur (X, Y, Z) des coordonnées d'un sommet par une matrice adéquate. Il existe des matrices pour la translation, la mise à l'échelle, d'autres pour la rotation, une autre pour la transformation de la caméra, une autre pour l'étape de projection, etc. Un changement de coordonnée s'effectue assez simplement en multipliant le vecteur-coordonnées (X, Y, Z) d'un sommet par une matrice adéquate. Un petit problème est que les matrices qui le permettent sont des matrices avec 4 lignes et 4 colonnes. Or, la multiplication demande que le nombre de coordonnées du vecteur soit égal au nombre de colonnes. Pour résoudre ce petit problème, on ajoute une 4éme coordonnée aux sommets, la coordonnée homogène, qui ne sert à rien, et est souvent mise à 1, par défaut. Mais oublions ce détail. Il se trouve que multiplier des matrices amène certaines simplifications. Au lieu de faire plusieurs multiplications de matrices, il est possible de fusionner les matrices en une seule, ce qui permet de simplifier les calculs. Ce qui fait que le placement des objets, changement de repère pour centrer la caméra, et d'autres traitements forts différents sont regroupés ensemble. Le traitement de la géométrie implique, sans surprise, des calculs de géométrie dans l'espace. Et cela implique des opérations mathématiques aux noms barbares : produits scalaires, produits vectoriels, et autres calculs impliquant des vecteurs et/ou des matrices. Et les calculs vectoriels/matriciels impliquent beaucoup d'additions, de soustractions, de multiplications, de division, mais aussi des opérations plus complexes : calculs trigonométriques, racines carrées, inverse d'une racine carrée, etc. Au final, un simple processeur peut faire ce genre de calculs, si on lui fournit le programme adéquat, l'implémentation est assez aisée. Mais on peut aussi implémenter le tout avec un circuit spécialisé, non-programmable. Les deux solutions sont possibles, tant que le circuit dispose d'assez de puissance de calcul. Les cartes graphiques anciennes contenaient un ou plusieurs circuits de multiplication de matrices spécialisés dans l'étape de transformation. Chacun de ces circuits prend un sommet et renvoie le sommet transformé. Ils sont composés d'un gros paquet de multiplieurs et d'additionneurs flottants. Pour plus d'efficacité, les cartes graphiques comportent plusieurs de ces circuits, afin de pouvoir traiter plusieurs sommets en même temps. ==L'élimination des surfaces cachées== Un point important du rendu 3D est que ce que certaines portions de la scène 3D ne sont pas visibles depuis la caméra. Et idéalement, les portions de la scène 3D qui ne sont pas visibles à l'écran ne doivent pas être calculées. A quoi bon calculer des choses qui ne seront pas affichées ? Ce serait gâcher de la puissance de calcul. Et pour cela, de nombreuses optimisations visent à éliminer les calculs inutiles. Elles sont regroupées sous les termes de '''''clipping''''' ou de '''''culling'''''. La différence entre ''culling'' et ''clipping'' n'est pas fixée et la terminologie n'est pas claire. Dans ce qui va suivre, nous n'utiliserons que le terme ''culling''. Les cartes graphiques modernes embarquent diverses méthodes de ''culling'' pour abandonner les calculs quand elles s’aperçoivent que ceux-ci portent sur une partie non-affichée de l'image. Cela fait des économies de puissance de calcul assez appréciables et un gain en performance assez important. Précisons que le ''culling'' peut être plus ou moins précoce suivant le type de rendu 3D utilisé, mais nous verrons cela dans la suite du chapitre. ===Les différentes formes de ''culling''/''clipping''=== La première forme de ''culling'' est le '''''view frustum culling''''', dont le nom indique qu'il s'agit de l'élimination de tout ce qui est situé en-dehors du ''view frustum''. Ce qui est en-dehors du champ de vision de la caméra n'est pas affiché à l'écran n'est pas calculé ou rendu, dans une certaine mesure. Le ''view frustum culling'' est assez trivial : il suffit d'éliminer ce qui n'est pas dans le ''view frustum'' avec quelques calculs de coordonnées assez simples. Quelques subtilités surviennent quand un triangle est partiellement dans le ''view frustrum'', ce qui arrive parfois si le triangle est sur un bord de l'écran. Mais rien d'insurmontable. [[File:View frustum culling.svg|centre|vignette|upright=1|''View frustum culling'' : les parties potentiellement visibles sont en vert, celles invisibles en rouge et celles partiellement visibles en bleu.]] Les autres formes de ''culling'' visent à éliminer ce qui est dans le ''view frustum'', mais qui n'est pas visible depuis la caméra. Pensez à des objets cachés par un autre objet plus proche, par exemple. Ou encore, pensez aux faces à l'arrière d'un objet opaque qui sont cachées par l'avant. Ces deux cas correspondent à deux types de ''culling''. L'élimination des objets masqués par d'autres est appelé l'''occlusion culling''. L'élimination des parties arrières d'un objet est appelé le ''back-face culling''. Dans les deux cas, nous parlerons d''''élimination des surfaces cachées'''. [[File:Occlusion culling example PL.svg|centre|vignette|''Occlusion culling'' : les objets en bleu sont visibles, ceux en rouge sont masqués par les objets en bleu.]] Le lancer de rayons n'a pas besoin d'éliminer les surfaces cachées, il ne calcule que les surfaces visibles. Par contre, la rastérisation demande d'éliminer les surfaces cachées. Sans cela, le rendu est incorrect dans le pire des cas, ou alors le rendu calcule des surfaces invisibles pour rien. Il existe de nombreux algorithmes logiciels pour implémenter l'élimination des surfaces cachées, mais la carte graphique peut aussi s'en charger. L'''occlusion culling'' demande de connaitre la distance à la caméra de chaque triangle. La distance à la caméra est appelée la '''profondeur''' du triangle. Elle est déterminée à l'étape de rastérisation et est calculée à chaque sommet. Lors de la rastérisation, chaque sommet se voit attribuer trois coordonnées : deux coordonnées x et y qui indiquent sa position à l'écran, et une coordonnée de profondeur notée z. ===L'algorithme du peintre=== Pour éliminer les surfaces cachées, la solution la plus simple consiste simplement à rendre les triangles du plus lointain au plus proche. L'idée est que si deux triangles se recouvrent totalement ou partiellement, on doit dessiner celui qui est derrière, puis celui qui est devant. Le dessin du second va recouvrir le premier. Quelque chose qui devrait vous rappeler le rendu 2D, où les sprites sont rendus du plus lointain au plus proche. Il ne s'agit ni plus ni moins que de l''''algorithme du peintre'''. [[File:Polygons cross.svg|vignette|Polygons cross]] Un problème est que la solution ne marche pas avec certaines configurations particulières, dans le cas où des polygones un peu complexes se chevauchent plusieurs fois. Il se présente rarement dans un rendu 3D normal, mais c'est quand même un cas qu'il faut gérer. Le problème est suffisant pour que cette solution ne soit plus utilisée dans le rendu 3D normal. Un autre problème est que l'algorithme demande de trier les triangles d'une scène 3D selon leur profondeur, du plus profond au moins profond. Et les cartes graphiques n'aiment pas ça, que ce soit les anciennes cartes graphiques comme les modernes. Il s'agit généralement d'une tâche qui est réalisée par le processeur, le CPU, qui est plus efficace que le GPU pour trier des trucs. Aussi, l'algorithme du peintre était utilisé sur d'anciennes cartes graphiques, qui ne géraient pas la géométrie mais seulement les textures et quelques effets de post-processing. Avec ces GPU, les jeux vidéo calculaient la géométrie et la triait sur le CPU, puis effectuaient le reste de la rastérisation sur le GPU. Les anciens jeux en 2.5D comme DOOM ou les DOOM-like, utilisaient une amélioration de l'algorithme du peintre. L'amélioration variait suivant le moteur de jeu utilisé, et donnait soit une technique dite de ''portal rendering'', soit un système de ''Binary Space Partionning'', assez complexes et difficiles à expliquer. Mais il ne s'agissait pas de jeux en 3D, les maps de ces jeux avaient des contraintes qui rendaient cette technique utilisable. Ils n'avaient pas de polygones qui se chevauchent, notamment. ===Le tampon de profondeur=== [[File:Z-buffer no text.jpg|vignette|Z-buffer correspondant à un rendu]] Une autre solution utilise ce qu'on appelle un '''tampon de profondeur''', aussi appelé un ''z-buffer''. Il s'agit d'un tableau, stocké en mémoire vidéo, qui mémorise la coordonnée z de l'objet le plus proche pour chaque pixel. Par défaut, ce tampon de profondeur est initialisé avec la valeur de profondeur maximale, celle du ''far plane'' du ''viewfrustum''. Au fur et à mesure que les objets seront calculés, le tampon de profondeur est mis à jour, conservant ainsi la trace de l'objet le plus proche de la caméra. Si jamais un triangle a une coordonnée z plus grande que celle du tampon de profondeur, cela veut dire qu'il est situé derrière un objet déjà rendu. Il est éliminé (sauf si transparence il y a) et le tampon de profondeur n'a pas à être mis à jour. Dans le cas contraire, l'objet est plus près de la caméra et sa coordonnée z remplace l'ancienne valeur z dans le tampon de profondeur. [[File:Z-buffer.svg|centre|vignette|upright=2.0|Illustration du processus de mise à jour du Z-buffer.]] Il existe des techniques alternatives pour coder la coordonnée de profondeur, qui se distinguent par le fait que la coordonnée z n'est pas proportionnelle à la distance entre le fragment et la caméra. Mais il s'agit là de détails assez mathématiques que je me permets de passer sous silence. Dans la suite de ce cours, nous allons juste parler de profondeur pour regrouper toutes ces techniques, conventionnelles ou alternatives. Toutes les cartes graphiques modernes utilisent un système de ''z-buffer''. C'est la seule solution pour avoir des performances dignes de ce nom. Il faut cependant noter qu'elles utilisent des tampons de profondeur légèrement modifiés, qui ne mémorisent pas la coordonnée de profondeur, mais une valeur dérivée. Pour simplifier, ils ne mémorisent pas la coordonnée de profondeur z, mais son inverse 1/z. Les raisons à cela ne peuvent pas encore être expliquées à ce moment du cours, aussi nous allons simplement dire que c'est une histoire de correction de perspective. Les coordonnées z et 1/z sont codées sur quelques bits, allant de 16 bits pour les anciennes cartes graphiques, à 24/32 bits pour les cartes plus récentes. De nos jours, les Z-buffer de 16 bits sont abandonnés et toutes les cartes graphiques utilisent des coordonnées z de 24 à 32 bits. La raison est que les Z-buffer de 16 bits ont une précision insuffisante, ce qui fait que des artefacts peuvent survenir. Si deux objets sont suffisamment proches, le tampon de profondeur n'a pas la précision suffisante pour discriminer les deux objets. Pour lui, les deux objets sont à la même place. Conséquence : il faut bien choisir un des deux objets et ce choix se fait pixel par pixel, ce qui fait des artefacts visuels apparaissent. On parle alors de '''''z-fighting'''''. Voici ce que cela donne : [[File:Z-fighting.png|centre|vignette|Z-fighting]] Un défaut du tampon de profondeur est qu'il ne gère pas correctement les objets transparents. Dès que de la transparence est présente dans une scène 3D, le tampon de profondeur ne peut pas être utilisé. Une solution pour cela est de rendre une scène 3D en deux phases : une pour les objets opaques, une avec les objets transparents. La où on rend les objets opaques utilise le tampon de profondeur, mais il est désactivé lors de la seconde. ==La rastérisation et les textures== Dans cette section, nous allons voir ensemble l'étape de rastérisation et l'étape de traitement des pixels. La rastérisation est difficile à expliquer, surtout que son rôle exact dépend de la technique de rendu utilisée. Pour simplifier, elle projette un rendu en 3D sur un écran en 2D. Une autre explication tout aussi vague est qu'elle s'occupe la traduction des triangles en un affichage pixelisé à l'écran. Elle détermine à quoi ressemble la scène visible sur l'écran. C'est par exemple lors de cette étape que sont appliquées certaines techniques de ''culling'', qui éliminent les portions non-visibles de l'image, ainsi qu'une correction de la perspective et diverses opérations d'interpolation dont nous parlerons dans plusieurs chapitres. La rastérisation et placage de textures sont deux opérations très liées entre elles. Il existe deux manières principales pour lier les textures à la géométrie : la méthode directe et la méthode inverse (''UV Mapping''). Et les deux font que la rastérisation se fait de manière très différente. Précisons cependant que les rendus les plus simples n'utilisent pas de textures du tout. Ils se contentent de colorier les triangles, voire d'un simple rendu en fil de fer basé sur du tracé de lignes. Dans la suite de cette section, nous allons voir les quatre types de rendu principaux : le rendu en fils de fer, le rendu colorié, et deux rendus utilisant des textures. ===Le rendu en fil de fer=== [[File:Obj lineremoval.png|vignette|Rendu en fil de fer d'un objet 3D.]] Le '''rendu 3D en fils de fer''' est illustré ci-contre. Il s'agit d'un rendu assez ancien, utilisé au tout début de la 3D, sur des machines qu'on aurait du mal à appeler ordinateurs. Il se contente de tracer des lignes à l'écran, lignes qui connectent deux sommets, qui ne sont autres que les arêtes de la géométrie de la scène rendue. Le tout était suffisant pour réaliser quelques jeux vidéos rudimentaires. Les tout premiers jeux vidéos utilisaient ce rendu, l'un d'entre eux étant Maze War, le tout premier FPS. {| |[[File:Maze war.jpg|vignette|Maze war]] |[[File:Maze representation using wireframes 2022-01-10.gif|centre|vignette|Maze representation using wireframes 2022-01-10]] |} Le monde est calculé en 3D, il y a toujours un calcul de la géométrie, la scène est rastérisée normalement, les portions invisbles de l'image sont retirées, mais il n'y a pas d'application de textures après rastérisation. A la place, un algorithme de tracé de ligne trace les lignes à l'écran. Quand un triangle passe l'étape de rastérisation, l'étape de rastérisation fournit la position des trois sommets sur l'écran. En clair, elle fournit les coordonnées de trois pixels, un par sommet. A la suite, un algorithme de tracé de ligne trace trois lignes, une par paire de sommet. L'implémentation demande juste d'avoir une unité de calcul géométrique, une unité de rastérisation, et un VDC qui supporte le tracé de lignes. Elle est donc assez simple et ne demande pas de circuits de gestion des textures ni de ROP. Le VDC écrit directement dans le ''framebuffer'' les lignes à tracer. Il a existé des proto-cartes graphiques spécialisées dans ce genre de rendu, comme le '''''Line Drawing System-1''''' de l'entreprise Eans & Sutherland. Nous détaillerons son fonctionnement dans quelques chapitres. ===Le rendu à primitives colorées=== [[File:MiniFighter.png|vignette|upright=1|Exemple de rendu pouvant être obtenu avec des sommets colorés.]] Une amélioration du rendu précédent utilise des triangles/''quads'' coloriés. Chaque triangle ou ''quad'' est associé à une couleur, et cette couleur est dessinée sur le triangle/''quad''après la rastérisation. Le rendu est une amélioration du rendu en fils de fer. L'idée est que chaque triangle/''quad'' est associé à une couleur, qui est dessinée sur le triangle/''quad'' après la rastérisation. La technique est nommée ''colored vertices'' en anglais, nous parlerons de '''rendu à maillage coloré'''. [[File:Malla irregular de triángulos modelizando una superficie convexa.png|centre|vignette|upright=2|Maillage coloré.]] La couleur est propagée lors des calculs géométriques et de la rastérisation, sans subir de modifications. Une fois un rendu en fils de fer effectué, la couleur du triangle est récupérée. Le triangle/''quad'' rendu correspond à un triangle/''quad'' à l'écran. Et l'intérieur de ce triangle/''quad'' est colorié avec la couleur transmise. Pour cela, on utilise encore une fois une fonction du VDC : celle du remplissage de figure géométrique. Nous l’avions vu en parlant des VDC à accélération 2D, mais elle est souvent prise en charge par les ''blitters''. Ils peuvent remplir une figure géométrique avec une couleur unique, on réutilise cette fonction pour colorier le triangle/''quad''. L'étape de rastérisation fournit les coordonnées des sommets de la figure géométrique, le ''blitter'' les utilise pour colorier la figure géométrique. Niveau matériel, quelques bornes d'arcade ont utilisé ce rendu. La toute première borne d'arcade utilisant le rendu à maillage coloré est celle du jeu I Robot, d'Atari, sorti en 1983. Par la suite, dès 1988, les cartes d'arcades Namco System 21 et les bornes d'arcades Sega Model 1 utilisaient ce genre de rendu. On peut s'en rendre compte en regardant les graphismes des jeux tournant sur ces bornes d'arcade. Des jeux comme Virtua Racing, Virtua Fighter ou Virtua Formula sont assez parlants à ce niveau. Leurs graphismes sont assez anguleux et on voit qu'ils sont basés sur des triangles uniformément colorés. Pour ceux qui veulent en savoir plus sur la toute première borne d'arcade en rendu à maillage colorée, la borne ''I Robot'' d'Atari, voici une vidéo youtube à ce sujet : * [https://www.youtube.com/watch?v=6miEkPENsT0 I Robot d'Atari, le pionnier de la 3D Flat.] ===Le placage de textures direct=== Les deux rendus précédents sont très simples, mais n'utilisent pas de textures. Et il est temps de voir les deux rendus qui utilisent des textures. Il y en a deux types, appelés rendu avec placage de texture direct et indirect, nous allons voir le '''rendu par placage de texture direct''' en premier. Et nous l'appellerons ''rendu direct'' dans ce qui suit, pour simplifier les explications. L'idée est assez simple et peut utiliser aussi bien des triangles que des ''quads'', mais nous allons partir du principe qu'elle utilise des '''''quads''''', à savoir que les objets 3D sont composés de quadrilatères. Lorsqu'un ''quad'' est rastérisé, sa forme à l'écran est un rectangle déformé par la perspective. On obtient un rectangle si le ''quad'' est vu de face, un trapèze si on le voit de biais. Et le ''sprite'' doit être déformé de la même manière que le ''quad''. L'idée est que tout quad est associé à une texture, à un sprite. La figure géométrique qui correspond à un ''quad'' à l'écran est remplie non pas par une couleur uniforme, mais par un ''sprite'' rectangulaire. Il suffit techniquement de recopier le ''sprite'' à l'écran, c'est à dire dans la figure géométrique, au bon endroit dans le ''framebuffer''. Le rendu direct est en effet un intermédiaire entre rendu 2D à base de ''sprite'' et rendu 3D moderne. La géométrie est rendue en 3D pour générer des ''quads'', mais ces ''quads'' ne servent à guider la copie des sprites/textures dans le ''framebuffer''. [[File:TextureMapping.png|centre|vignette|upright=2|Exemple caricatural de placage de texture sur un ''quad''.]] La subtilité est que le sprite est déformé de manière à rentrer dans un quadrilatère, qui n'est pas forcément un rectangle à l'écran, mais est déformé par la perspective et son orientation en 3D. Le sprite doit être déformé de deux manières : il doit être agrandi/réduit en fonction de la taille de la figure affichée à l'écran, tourné en fonction de l'orientation du ''quad'', déformé pour gérer la perspective. Pour cela, il faut connaitre les coordonnées de profondeur de chaque bord d'un ''quad'', et de faire quelques calculs. N'importe quel VDC incluant un ''blitter'' avec une gestion du zoom/rotation des sprites peut le faire. : Si on veut avoir de beaux graphismes, il vaut mieux appliquer un filtre pour lisser le sprite envoyé dans le trapèze, filtre qui se résume à une opération d'interpolation et n'est pas très différent du filtrage de texture qui lisse les textures à l'écran. Un autre point est que les ''quads'' doivent être rendus du plus lointain au plus proche. Sans cela, on obtient rapidement des erreurs de rendu. L'idée est que si deux quads se chevauchent, on doit dessiner celui qui est derrière, puis celui qui est devant. Le dessin du second va recouvrir le premier. L'écriture du sprite du second quad écrasera les données du premier quad, pour les portions recouvertes, lors de l'écriture du sprite dans le ''framebuffer''. Quelque chose qui devrait vous rappeler le rendu 2D, où les sprites sont rendus du plus lointain au plus proche. Le rendu inverse utilise très souvent des triangles pour la géométrie, alors que le rendu direct a tendance à utiliser des ''quads'', mais il ne s'agit pas d'une différence stricte. L'usage de triangles/''quads'' peut se faire aussi bien avec un rendu direct comme avec un rendu inverse. Cependant, le rendu en ''quad'' se marie très bien au rendu direct, alors que le rendu en triangle colle mieux au rendu inverse. L'avantage de cette technique est qu'on parcourt les textures dans un ordre bien précis. Par exemple, on peut parcourir la texture ligne par ligne, l'exploiter par blocs de 4*4 pixels, etc. Et accéder à une texture de manière prédictible se marie bien avec l'usage de mémoires caches, ce qui est un avantage en matière de performances. Mais un même pixel du ''framebuffer'' est écrit plusieurs fois quand plusieurs quads se superposent, alors que le rendu inverse gère la situation avec une seule écriture (sauf si usage de la transparence). De plus, la gestion de la transparence était compliquée et les jeux devaient ruser en utilisation des solutions logicielles assez complexes. Niveau implémentation matérielle, une carte graphique en rendu direct demande juste trois circuits. Le premier est un circuit de calcul géométrique, qui rend la scène 3D. Le tri des quads est souvent réalisé par le processeur principal, et non pas par un circuit séparé. Toutes les étapes au-delà de l'étape de rastérisation étaient prises en charge par un VDC amélioré, qui écrivait des sprites/textures directement dans le ''framebuffer''. {|class="wikitable" |- ! Géométrie | Processeurs dédiés programmé pour émuler le pipeline graphique |- ! Tri des quads du plus lointain au plus proche | Processeur principal (implémentation logicielle) |- ! Application des textures | ''Blitter'' amélioré, capable de faire tourner et de zoomer sur des ''sprites''. |} L'implémentation était très simple et réutilisait des composants déjà existants : des VDC 2D pour l'application des textures, des processeurs dédiés pour la géométrie. Les unités de calcul de la géométrie étaient généralement implémentées avec un ou plusieurs processeurs dédiés. Vu qu'on savait déjà effectuer le rendu géométrique en logiciel, pas besoin de créer un circuit sur mesure. Il suffisait de dédier un processeur spécialisé rien que pour les calculs géométriques et on lui faisait exécuter un code déjà bien connu à la base. En clair, ils utilisaient un code spécifique pour émuler un circuit fixe. C'était clairement la solution la plus adaptée pour l'époque. Les unités géométriques étaient des processeurs RISC, normalement utilisés dans l'embarqué ou sur des serveurs. Elles utilisaient parfois des DSP. Pour rappel, les DSP des processeurs de traitement de signal assez communs, pas spécialement dédiés aux rendu 3D, mais spécialisé dans le traitement de signal audio, vidéo et autre. Ils avaient un jeu d'instruction assez proche de celui des cartes graphiques actuelles, et supportaient de nombreuses instructions utiles pour le rendu 3D. Le rendu direct a été utilisé dans la période de transition entre rendu 2D et rendu 3D, car il était très adapté pour faire cette transition. Coupler un VDC à un processeur pour la géométrie était particulièrement simple à l'époque. Le rendu direct a été utilisé sur des bornes d'arcade dès les années 90. Outre les bornes d'arcade, quelques consoles de 5ème génération utilisaient le rendu direct, avec les mêmes solutions matérielles. La géométrie était calculée sur plusieurs processeurs dédiés. Le reste du pipeline était géré par un VDC 2D qui implémentait le placage de textures. Deux consoles étaient dans ce cas : la 3DO, et la Sega Saturn. Le rendu direct est aujourd'hui abandonné. ===Le placage de textures inverse=== Le rendu précédent, le rendu direct, permet d'appliquer des textures directement dans le ''framebuffer''. Mais comme dit plus haut, il existe une seconde technique pour plaquer des textures, appelé le '''placage de texture inverse''', aussi appelé l'''UV Mapping''. Elle associe une texture complète pour un modèle 3D,contrairement au placage de tecture direct qui associe une texture par ''quad''/triangle. L'idée est que l'on attribue un texel à chaque sommet. Plus précisémment, chaque sommet est associé à des '''coordonnées de texture''', qui précisent quelle texture appliquer, mais aussi où se situe le texel à appliquer dans la texture. Par exemple, la coordonnée de texture peut dire : je veux le pixel qui est à ligne 5, colonne 27 dans cette texture. La correspondance entre texture et géométrie est réalisée lorsque les créateurs de jeu vidéo conçoivent le modèle de l'objet. [[File:Texture Mapping example.png|centre|vignette|upright=2|Exemple de placage de texture.]] Dans les faits, on n'utilise pas de coordonnées entières de ce type, mais deux nombres flottants compris entre 0 et 1. La coordonnée 0,0 correspond au texel en bas à gauche, celui de coordonnée 1,1 est tout en haut à droite. L'avantage est que ces coordonnées sont indépendantes de la résolution de la texture, ce qui aura des avantages pour certaines techniques de rendu, comme le ''mip-mapping''. Les deux coordonnées de texture sont notées u,v avec DirectX, ou encore s,t dans le cas général : u est la coordonnée horizontale, v la verticale. [[File:UVMapping.png|centre|vignette|upright=2|UV Mapping]] Avec le placage de texture inverse, la rastérisation se fait grosso-modo en trois étapes : la rastérisation proprement dite, le placage de textures, et les opérations finales qui écrivent un pixel dans le ''framebuffer''. Au niveau du matériel, ainsi que dans la plupart des API 3D, les trois étapes sont réalisées par des circuits séparés. [[File:01 3D-Rasterung-a.svg|vignette|Illustration du principe de la rasterization. La surface correspondant à l'écran est subdivisée en pixels carrés, de coordonnées x et y. La caméra est placée au point e. Pour chaque pixel, on trace une droite qui part de la caméra et qui passe par le pixel considéré. L'intersection entre une surface et cette droite se fait en un point, appartenant à un triangle.]] Lors de la rasterisation, chaque triangle se voit attribuer un ou plusieurs pixels à l'écran. Pour bien comprendre, imaginez une ligne droite qui part de caméra et qui passe par un pixel sur le plan de l'écran. Cette ligne intersecte 0, 1 ou plusieurs objets dans la scène 3D. Les triangles situés ces intersections entre cette ligne et les objets rencontrés seront associés au pixel correspondant. L'étape de rastérisation prend en entrée un triangle et renvoie la coordonnée x,y du pixel associé. Il s'agit là d'une simplification, car un triangle tend à occuper plusieurs pixels sur l'écran. L'étape de rastérisation fournit la liste de tous les pixels occupés par un triangle, et les traite un par un. Quand un triangle est rastérisé, le rasteriseur détermine la coordonnée x,y du premier pixel, applique une texture dessus, puis passe au suivant, et rebelote jusqu'à ce que tous les pixels occupés par le triangles aient été traités. L'implémentation matérielle du placage de texture inverse est beaucoup plus complexe que pour les autres techniques. Pour être franc, nous allons passer le reste du cours à parler de l'implémentation matérielle du placage de texture inverse, ce qui prendra plus d'une dizaine de chapitres. ==La transparence, les fragments et les ROPs== Dans ce qui suit, nous allons parler uniquement de la rastérisation avec placage de textures inverse. Les autres formes de rastérisation ne seront pas abordées. La raison est que tous les GPUs modernes utilisent cette forme de rastérisation, les exceptions étant rares. De même, ils utilisent un tampon de profondeur, pour l'élimination des surfaces cachées. La rastérisation effectue donc des calculs géométriques, suivis d'une étape de rastérisation, puis de placage des textures. Ces trois étapes sont réalisées par une unité géométrique, une unité de rastérisation, et un circuit de placage de textures. Du moins sur le principe, car les cartes graphiques modernes ont fortement optimisé l'implémentation et n'ont pas hésité à fusionner certains circuits. Mais nous verrons cela en temps voulu, nous n'allons pas résumer plusieurs décennies d'innovation technologique en quelques paragraphes. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Placage de textures |} Mais où mettre le tampon de profondeur ? Intuitivement, on se dit qu'il vaut mieux faire l'élimination des surfaces cachées le plus tôt possible, dès que la coordonnée de profondeur est connue. Et elle est connu à l'étape de rastérisation, une fois les sommets transformés. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Tampon de profondeur | Placage de textures |} En réalité, la profondeur des fragments est gérée par un circuit appelé le '''''Raster Operations Pipeline''''' (ROP), situé à la toute fin du pipeline graphique. Dans ce qui suit, nous utiliserons l'abréviation ROP pour simplifier les explications. Le ROP effectue quelques traitements sur les fragments, avant d'enregistrer l'image finale dans la mémoire vidéo. Il est placé à la fin du pipeline pour gérer correctement la transparence. Et nous allons voir pourquoi la transparence est gérée à la fin du pipeline. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Placage de textures | ''Raster Operations Pipeline'' |} ===Le mélange ''alpha''=== La transparence se manifeste quand plusieurs objets sont l'un derrière l'autre. Histoire de simplifier les explications, nous allons d'abord voir le cas où un objet semi-transparent est devant un objet opaque. La couleur perçue est alors un mélange de la couleur de l'objet opaque et celle de l'objet semi-transparent. Le mélange dépend d'à quel point l'objet semi-transparent est transparent. Avec un objet parfaitement transparent, seul l'objet opaque est visible. Avec un objet à moitié transparent, la couleur finale sera pour moitié celle de l'objet opaque, pour moitié celle de l'objet semi-transparent. Et c'est pareil pour les cas intermédiaires entre un objet totalement transparent et un objet totalement opaque. La transparence d'un objet/pixel est définie par un nombre, appelé la '''composante ''alpha'''''. Plus la composante alpha est élevée, plus le pixel est opaque. Elle vaut 0 pour un objet opaque et 1 pour un objet transparent. Elle est ajoutée aux composantes RGB, ce qui fait que tout fragment contient une "couleur de transparence" en plus des couleurs RGB. Elle agit comme un coefficient qui dit comment mélanger la couleur d'un objet transparent et d'un objet opaque. Le calcul de la transparence est une moyenne pondérée par la composante alpha. On parle alors d''''''alpha blending'''''. : <math>\text{Couleur finale} = \alpha \times \text{Couleur de l'objet transparent} + (1 - \alpha) \times \text{Couleur de l'objet opaque}</math> [[File:Texture splatting.png|centre|vignette|upright=2.0|Calcul de transparence. La première ligne montre le produit pour l'objet transparent, la seconde ligne est celle de l'objet opaque. La troisième ligne est celle de l'addition finale.]] Maintenant, qu'en est-il du cas où plusieurs objets sont superposés ? Si vous tracez une demi-droite dont l'origine est la caméra et qui passe par le pixel, il arrive qu'elle intersecte la géométrie en plusieurs points, un point par objet sur la ligne du regarde. Sans transparence, l'objet le plus proche cache tous les autres et c'est donc lui qui décide de la couleur du pixel. Mais avec un objet transparent, la couleur finale est un mélange de la couleur de plusieurs points d'intersection. Il faut donc calculer un pseudo-pixel pour chaque point d'intersection, auquel on donne le nom de '''fragment'''. Un fragment possède une position à l'écran, une coordonnée de profondeur, une couleur, ainsi que quelques autres informations potentiellement utiles. Les fragments attribués à un même pixel, qui sont à la même position sur l'écran, sont combinés pour obtenir la couleur finale de ce pixel. Il est possible d'utiliser le mélange ''alpha'' pour cela. Il suffit de faire le mélange ''alpha'' entre le fragment qui vient d'être calculé, et le pixel dans le ''framebuffer''. Pour cela, le fragment a une composante ''alpha'', qui est ajouté aux trois couleurs RGB. Le pixel déjà dans le ''framebuffer'' est un résultat temporaire, né du mélange ''alpha'' de tous les fragments précédents. Un défaut de cette méthode est qu'elle fonctionne assez mal avec un tampon de profondeur. Si le tampon de profondeur est activé, le mélange ''alpha'' ne fonctionne que si les objets sont rendus du plus lointain au plus proche. Et procéder dans cet ordre a un défaut : on dessine des objets dans le ''framebuffer'', pour qu'ensuite les objets devant écrasent ce qui a déjà été dessiné. Un même pixel peut donc être dessiné plusieurs fois, dont une seule sera pertinente. Et ces écritures utilisent de la bande passante mémoire, qui est une ressource précieuse sur un GPU moderne. Il s'agit d'un phénomène appelé '''''overdraw''''', ou sur-dessinage en français. Quelques optimisations permettent d'éliminer l'''overdraw'' en rendant les objets du plus proche au plus lointain, d'autres permettent de dessiner des objets dans un ordre arbitraire, mais nous ne pouvons pas en parler ici. Beaucoup de moteurs 3D rendent séparément les objets opaques et transparents. Une première passe rend les objets opaques, puis les objets transparents sont rendus dans une seconde passe. Les objets opaques sont rendus dans le désordre, ce qui fait qu'on n'a pas à les trier, alors que les objets transparents doivent être triés selon leur distance. un autre avantage est que le mélange ''alpha'' est désactivé lors de la première passe, alors que c'est la mise à jour du tampon de profondeur qui est désactivé lors de la seconde passe, ce qui augmente un peu les performances dans les deux cas. ===Le test ''alpha''=== Le test ''alpha'' est une technique qui permet d'annuler le rendu d'un fragment en fonction de sa transparence. Si la composante alpha est en-dessous ou au-dessus d'un seuil, le fragment est simplement abandonné. Le seuil en question est configurable, de même que la comparaison utilisée : on peut éliminer le fragment si sa transparence est au-dessus d'un certain seuil, en-dessous, égal, différent, etc. Il s'agit d'une optimisation qui est utile dans certains scénarios spécifiques. Par exemple, si l'objet a une transparence très élevée, du genre 95%, autant le compter comme complétement transparent, afin d'éviter des opérations de mélange ''alpha''. En effet, les opérations de mélange ''alpha'' sont très lentes, car elles demandent de faire des opérations de lecture-écriture en mémoire vidéo : on lit un pixel dans le ''framebuffer'', on applique le mélange ''alpha'' et on écrit le résultat en mémoire vidéo. L'''alpha test'' permet donc de gagner en performance au prix d'une baisse de la qualité d'image. Il y a cependant des cas où l'usage du test ''alpha'' est primordial, au-delà d'une question de performances. Un exemple classique est celui du rendu du feuillage dans un jeu 3D. Un feuillage est composé en assemblant plusieurs images de feuilles. Chaque feuille est un carré sur lequel on place une texture de feuille, qui est opaque pour la partie verte des feuilles, transparente pour le reste. Les carrés ne sont cependant pas superposés, mais s'intersectent fortement, ce qui fait que le mélange ''alpha'' ne donne pas de bons résultats. L'usage du test ''alpha'' permet d'obtenir un rendu correct. Pour d'informations via ce lien : * [https://bgolus.medium.com/anti-aliased-alpha-test-the-esoteric-alpha-to-coverage-8b177335ae4f Anti-aliased Alpha Test: The Esoteric Alpha To Coverage]. ===Les effets de brouillard=== Les '''effets de brouillard''' sont nécessaires dans certains jeux vidéo pour l'ambiance (pensez à des jeux d'horreur comme Silent Hill), mais ils ont surtout été utilisés pour économiser des calculs. L'idée est de ne pas calculer les graphismes au-delà d'une certaine distance, sans que cela se voie. Le ''view frustum'' utilise alors un plan limite, au-delà duquel on ne voit pas les objets. Mais ce plan limite donne une cassure inesthétique dans le rendu. Pour masquer cette cassure, les programmeurs ajoutaient un effet de brouillard. Les objets au-delà du plan limite étaient totalement dans le brouillard, puis ce brouillard se réduisait progressivement en se rapprochant de la caméra, avant de s'annuler à partir d'une certaine distance. Pour calculer le brouillard, on effectue un mélange ''alpha'' entre la couleur du pixel et une ''couleur de brouillard''. La différence est que l'on n'utilise pas la transparence pour faire le mélange, mais un '''coefficient de brouillard''', noté <math>\text{fog}(z)</math>. : <math>\text{Couleur finale} = \text{fog}(z) \times \text{Couleur de brouillard} + [ 1 - \text{fog}(z) ] \times \text{Couleur du pixel}</math> Le coefficient de brouillard dépend de la coordonnée de profondeur, de la distance du pixel par rapport à la caméra. Le brouillard démarre à une distance <math>z_{fog-start}</math>, et masque totalement les objets à partir d'une distance <math>z_{fog-end}</math>. Entre les deux, le coefficient de brouillard dépend de la distance. OpenGL autorise trois formules de calcul suivantes : : <math>\text{fog}(z) = \frac{z_{fog-end} - z}{z_{fog-end} - z_{fog-start}}</math> : <math>\text{fog}(z) = e^{- k \times z}</math> : <math>\text{fog}(z) = e^{- (k \times z)^2}</math> ==L'éclairage d'une scène 3D== L'éclairage d'une scène 3D calcule les ombres, mais aussi la luminosité de chaque pixel, ainsi que bien d'autres effets graphiques. Les algorithmes d'éclairage ont longtemps été implémentés directement en matériel, les cartes graphiques géraient l'éclairage dans des circuits spécialisés. Aussi, il est important de voir ces algorithmes d'éclairage. Il est possible d'implémenter l'éclairage à deux endroits différents du pipeline : juste avant la rastérisation, et après la rastérisation. ===Les sources de lumière et les couleurs associées=== L'éclairage d'une scène 3D provient de sources de lumières, comme des lampes, des torches, le soleil, etc. Il existe de nombreux types de sources de lumière, et nous n'allons parler que des principales. Elles sont au nombre de quatre et elles sont illustrées ci-dessous. [[File:3udUJ.gif|centre|vignette|upright=2|Types de sources de lumière.]] [[File:Graphics lightmodel directional.png|vignette|upright=1.0|Source de lumière directionnelle.]] Les '''sources directionnelles''' servent à modéliser des sources de lumière très éloignées, comme le soleil ou la lune. Elles sont simplement définies par un vecteur qui indique la direction de la lumière, rien de plus. Les '''sources ponctuelles''' sont des points, qui émettent de la lumière dans toutes les directions. Elles sont définies par une position, et une intensité lumineuse, éventuellement la couleur de la lumière émise. Il existe deyux types de sources de lumière ponctuelles. * Le premières émettent de manière égale dans toutes les directions. Elles sont appelées des ''point light'' dans le schéma du dessus. * Les secondes émettent de la lumière dans une '''direction privilégiée'''. L'exemple le plus parlant est celui d'une lampe-torche : elle émet de la lumière "tout droit", dans la direction où la lampe est orientée. Elles sont appelées des ''sport light'' dans le schéma du dessus. La direction privilégiée est un vecteur, notée v dans le schéma du dessous. [[File:Graphics lightmodel ambient.png|vignette|upright=1.0|Lumière ambiante.]] En théorie, la lumière rebondit sur les surfaces et a tendance à se disperser un peu partout à force de rebondir. C'est ce qui explique qu'on arrive à voir à l'intérieur d'une pièce si une fenêtre est ouverte. Il en résulte un certain '''éclairage ambiant''', qui est assez difficile à représenter dans un moteur de rendu 3D. Auparavant, l'éclairage ambiant était simulé par une lumière égale en tout point de la scène 3D, appelée simplement la '''lumière ambiante'''. Précisément, on suppose que la lumière ambiante en un point vient de toutes les directions et a une intensité constante, identique dans toutes les directions. Le tout est illustré ci-contre. C'est assez irréaliste, mais ça donne une bonne approximation de la lumière ambiante. ===La lumière incidente : le terme géométrique=== Pour simplifier, nous allons supposer que l'éclairage est calculé pour chaque sommet, pas par triangle. C'est de loin le cas le plus courant, aussi ce n'est pas une simplification abusive. La lumière qui arrive sur un sommet est appelée la '''lumière incidente'''. La couleur d'un sommet dépend de deux choses : la lumière incidente directe, comment il réfléchit cette lumière. Mathématiquement, il est possible de résumer cela avec le produit de deux termes : l'intensité de la lumière incidente, une fonction qui indique comment la surface réfléchit la lumière incidente. La fonction en question est appelée la '''réflectivité bidirectionnelle'''. Le terme anglais est ''bidirectional reflectance distribution function'', abrévié en BRDF, et nous utiliserons cette abréviation dans ce qui suit. : <math>\text{Couleur finale} = \text{Lumière incidente} \times BRDF(...)</math> La lumière incidente vient soit directement des sources de lumière, soit de la lumière qui a rebondit sur d'autres objets proches. La première est appelée la lumière directe, celle qui vient des rebonds s'appelle la lumière indirecte. Pour simplifier, la lumière indirecte est gérée par la lumière ambiante, nous passons sous silence les techniques d'illumination globale. En clair : nous allons nous limiter au cas où la lumière incidente vient directement d'une source de lumière, pas d'un rebond. Intuitivement, la lumière incidente est simplement égale à l'intensité de la source de lumière. Sauf que ce n'est qu'une approximation, et une assez mauvaise. En réalité, l'approximation est bonne si la lumière arrive proche de la verticale, mais elle est d'autant plus mauvaise que la lumière arrive penchée, voire rasante. La raison : la lumière incidente sera étalée sur une surface plus grande, si elle arrive penchée. Si vous vous souvenez de vos cours de collège, c'est le même principe qui explique les saisons. La lumière du soleil est proche de la verticale en été, mais est de plus en plus penché quand on s'avance vers l'Hiver. La lumière solaire est donc étalée sur une surface plus grande, ce qui fait qu'un point de la surface recevra moins de lumière, celle-ci étant diluée, étalée. [[File:Radiación solar.png|centre|vignette|upright=2|Exemple avec la lumière solaire.]] [[File:Angle of incidence.svg|vignette|upright=1|Angle d'incidence.]] En clair, tout dépend de l''''angle d'incidence''' de la lumière. Reste à voir comment calculer cet angle. La lumière incidente est définie par un vecteur, qui part de la source de lumière et atterrit sur le sommet considéré. Imaginez simplement que ce vecteur suit un rayon lumineux provenant de la source de lumière. Le vecteur pour la lumière incidente sera noté L. L'angle d'incidence est l'angle que fait ce vecteur avec la verticale de la surface, au niveau du sommet considéré. [[File:Graphics lightmodel ptsource.png|vignette|Normale de la surface.]] Pour cela, les calculs d'éclairage ont besoin de connaitre la verticale d'un sommet. Un sommet est donc associé à un vecteur, appelé la '''normale''', qui indique la verticale en ce point. Deux sommets différents peuvent avoir deux normales différentes, même s'ils sont proches. Elles sont d'autant plus différentes que la surface est rugueuse, non-lisse. La normale est prédéterminée lors de la création du modèle 3D, il n'y a pas besoin de le calculer. Par contre, elle est modifiée lors de l'étape de transformation, quand on place le modèle 3D dans la scène 3D. Les deux autres vecteurs sont à calculer à chaque image, car ils changent quand on bouge le sommet. La lumière qui arrive sur la surface dépend de l'angle entre la normale et le vecteur L. Précisément, elle dépend du cosinus de cet angle. En multipliant ce cosinus avec l'intensité de la lumière, on a la lumière arrivante. La couleur finale d'un pixel est donc : : <math>\text{Couleur finale} = I \times \cos{(N, L)} \times BRDF(...)</math> Le terme <math>I \times \cos{N, L}</math> ne dépend pas de la surface considérée. Juste de la position de la source de lumière, de la position du sommet et de son orientation par rapport à la lumière. Aussi, il est parfois appelé le '''terme géométrique''', en opposition aux propriétés de la surface. Les propriétés de la surface sont définies par un '''''material''''', qui indique comment il réfléchit la lumière, ainsi que sa texture. ===Le produit scalaire de deux vecteurs=== Calculer le terme géométrique demande de calculer le cosinus d'un angle. Et il n'est pas le seul : les autres calculs d'éclairage que nous allons voir demandent de calculer des cosinus. Or, les calculs trigonométriques sont très gourmands pour le GPU. Pour éviter le calcul d'un cosinus, les GPU utilisent une opération mathématique appelée le ''produit scalaire''. Le produit scalaire agit sur deux vecteurs, que l'on notera A et B. Un produit scalaire prend : la longueur des deux vecteurs, et l'angle entre les deux vecteurs noté <math>\omega</math>. Le produit scalaire est équivalent à la formule suivante : : <math>\text{Produit scalaire de deux vecteurs A et B} = \vec{A} \cdot \vec{B} = A \times B \times \cos{(\omega)}</math>, avec A et B la longueur des deux vecteurs A et B. L'avantage est que le produit scalaire se calcule simplement avec des additions, soustractions et multiplications, des opérations que les cartes graphiques savent faire très facilement. Le produit scalaire de deux vecteurs de coordonnées x,y,z est le suivant : : <math>\vec{A} \cdot \vec{B} = x_A \times x_B + y_A \times y_B + z_A \times z_B</math> En clair, on multiplie les coordonnées identiques, et on additionne les résultats. Rien de compliqué. Un avantage est que tous les vecteurs vus précédemment sont normalisés, à savoir qu'ils ont une longueur qui vaut 1. Ainsi, le calcul du produit scalaire devient équivalent au calcul du produit scalaire. ===La réflexion de la lumière sur la surface=== [[File:Ray Diagram 2.svg|vignette|Reflection de la lumière sur une surface parfaitement lisse.]] Maintenant que nous venons de voir le terme géométrique, voyons le BRDF, qui définit comment la surface de l'objet 3D réfléchit la lumière. Vos cours de collège vous ont sans doute appris que la lumière est réfléchie avec le même angle d'arrivée. L'angle d'incidence et l'angle de réflexion sont égaux, comme illustré ci-contre. On parle alors de '''réflexion parfaite'''. Mais cela ne vaut que pour une surface parfaitement lisse, comme un miroir parfait. Dans la réalité, une surface a tendance à renvoyer des rayons dans toutes les directions. La raison est qu'une surface réelle est rugueuse, avec de petites aspérités et des micro-reliefs, qui renvoient la lumière dans des directions "aléatoires". La lumière « rebondit » sur la surface de l'objet et une partie s'éparpille dans un peu toutes les directions. On parle alors de '''réflexion diffuse'''. {| |- |[[File:Dioptre reflexion diffuse speculaire refraction.svg|vignette|upright=1.4|Différence entre réflexion diffuse et spéculaire.]] |[[File:Diffuse reflection.svg|vignette|upright=1|Réflexion diffuse.]] |} Maintenant, imaginons que la surface n'ait qu'une réflexion diffuse, pas d'autres formes de réflexion. Et imaginons aussi que cette réflexion diffuse soit parfaite, à savoir que la lumière réfléchie soit renvoyée à l'identique dans toutes les directions, sans aucune direction privilégiée. On a alors le ''material'' le plus simple qui soit, appelé un '''''diffuse material'''''. Vu que la lumière est réfléchie à l'identique dans toutes les directions, elle sera identique peu importe où on place la caméra. La lumière finale ne dépend donc que des propriété de la surface, que de sa couleur. En clair, il suffit de donner une '''couleur diffuse''' à chaque sommet. La couleur diffuse est simplement multipliée par le terme géométrique, pour obtenir la lumière réfléchie finale. Rien de plus, rien de moins. Cela donne l'équation suivante, avec les termes suivants : * L est le vecteur pour la lumière incidente ; * N est la normale du sommet ; * I est l'intensité de la source de lumière ; * <math>C_d</math> est la couleur diffuse. : <math>\text{Illumination diffuse} = C_d \times \left[ I \times (\vec{N} \cdot \vec{L}) \right]</math> Rajoutons maintenant l'effet de la lumière ambiante à un ''material'' de ce genre. Pour rappel, la lumière ambiante vient de toutes les directions à part égale, ce qui fait que son angle d'incidence n'a donc pas d'effet. L'intensité de la lumière ambiante est déterminée lors de la création de la scène 3D, c'est une constante qui n'a pas à être calculée. Pour obtenir l'effet de la lumière ambiante sur un objet, il suffit de multiplier sa couleur diffuse par l'intensité de la lumière ambiante. Cependant, de nombreux moteurs de jeux ajoutent une '''couleur ambiante''', différente de la couleur diffuse. : <math>\text{Illumination ambiante} = C_a \times I_a</math> avec <math>C_a</math> la couleur ambiante du point de surface et <math>I_a</math> l'intensité de la lumière ambiante. En plus de la réflexion diffuse parfaite, de nombreux matériaux ajoutent une '''réflexion spéculaire''', qui n'est pas exactement la réflexion parfaite, en est très proche. Les rayons réfléchis sont très proches de la direction de réflexion parfaite, et s'atténuent très vite en s'en éloignant. Le résultat ressemble à une sorte de petit "point blanc", très lumineux, orienté vers la source de lumière, appelé le '''''specular highlight'''''. La réflexion diffuse est prédominante pour les matériaux rugueux, alors que la réflexion spéculaire est dominante sur les matériaux métalliques ou très lisses. [[File:Phong components version 4.png|centre|vignette|upright=3.0|Couleurs utilisées dans l'algorithme de Phong.]] [[File:Phong Vectors.svg|vignette|Vecteurs utilisés dans l'algorithme de Phong (et dans le calcul de l'éclairage, de manière générale).]] Pour calculer la réflexion spéculaire, il faut d'abord connaitre le vecteur pour la réflexion parfaite, que nous noterons R dans ce qui suit. Le vecteur R peut se calculer avec la formule ci-dessous : : <math>\vec{R} = 2 (\vec{L} \cdot \vec{N}) \times \vec{N} - \vec{L} </math> La réflexion spéculaire dépend de l'angle entre la direction du regard et la normale : plus celui-ci est proche de l'angle de réflexion parfaite, plus la réflexion spéculaire sera intense. Le vecteur pour la direction du regard sera noté V, pour vue ou vision. La réflexion spéculaire est une fonction qui dépend de l'angle entre les vecteurs R et V. Le calcul de la réflexion spéculaire utilise une '''couleur spéculaire''', qui est l'équivalent de la couleur diffuse pour la réflexion spéculaire. : <math>\text{BRDF spéculaire} = C_s \times f(\vec{R} \cdot \vec{V}) </math> La fonction varie grandement d'un modèle de calcul spéculaire à l'autre. Aussi, je ne rentre pas dans le détail. L'essentiel est que vous compreniez que le calcul de l'éclairage utilise de nombreux calculs géométriques, réalisés avec des produits scalaires. Les calculs géométriques utilisent la couleur d'un sommet, la normale du sommet, et le vecteur de la lumière incidente. Les autres informations sont calculées à l'exécution. ===Les algorithmes d'éclairage basiques : par triangle, par sommet et par pixel=== Dans tout ce qui a été dit précédemment, l'éclairage est calculé pour chaque sommet, ce qui fait qu'on parle d''''éclairage par sommet''', ou ''vertex lighting''. Il attribue une illumination/couleur à chaque sommet de la scène 3D. Mais une fois qu'on a obtenu la couleur des sommets, reste à colorier les triangles. Pour obtenir la couleur finale d'un pixel, l'éclairage par sommet fait une moyenne de la couleur de chaque sommet. La moyenne est une moyenne pondérée, qui tient compte de la distance du sommet avec le pixel. Plus le pixel est loin d'un sommet, plus son coefficient est petit. Typiquement, le coefficient varie entre 0 et 1 : de 1 si le pixel est sur le sommet, à 0 si le pixel est sur un des sommets adjacents. La moyenne effectuée est généralement une interpolation bilinéaire, qui est prise en charge par l'étape de rastérisation. L'éclairage par sommet a eu son heure de gloire, mais il est maintenant remplacé par l''''éclairage par pixel''' (''per-pixel lighting''), qui calcule l'éclairage pixel par pixel. En clair, l’éclairage est finalisé après l'étape de rastérisation, il ne se fait pas qu'au niveau de la géométrie. Il existe plusieurs types d'éclairage par pixel, mais on peut les classer en deux grands types : l'éclairage de Phong et le ''bump/normal mapping''. L''''éclairage de Phong''' calcule l'éclairage pixel par pixel. Avec cet algorithme, la géométrie n'est pas éclairée : les couleurs des sommets ne sont pas calculées. A la place, les normales sont envoyées à l'étape de rastérisation, qui effectue une opération d'interpolation, qui renvoie une normale pour chaque pixel. Les calculs d'éclairage utilisent alors ces normales pour faire les calculs d'éclairage pour chaque pixel. {| |- |[[File:Per face lighting.png|vignette|upright=1|Eclairage par triangle.]] |[[File:Per vertex lighting.png|vignette|upright=1|Eclairage par sommet.]] |[[File:Per fragment lighting.png|vignette|upright=1|Eclairage de Phong.]] |- |[[File:Per face lighting example.png|vignette|upright=1|Eclairage par triangle.]] |[[File:Per vertex lighting example.png|vignette|upright=1|Eclairage par sommet.]] |[[File:Per fragment lighting example.png|vignette|upright=1|Eclairage de Phong.]] |} La technique du '''''normal mapping''''' est assez simple à expliquer, sans compter que plusieurs cartes graphiques l'ont implémentée directement dans leurs circuits. Là où l'éclairage de Phong interpole les normales pour chaque pixel, le ''normal-mapping'' précalcule les normales d'une surface dans une texture, appelée la ''normal-map''. Lors du rendu 3D, la carte graphique lit les normales adéquates directement depuis cette texture, puis fait les calculs d'éclairage avec. Le ''normal mapping'' permettent d'ajouter du relief et des détails sur des surfaces planes en jouant sur l'éclairage. Il permet de simplifier grandement la géométrie rendue, tout en utilisant l'éclairage pour compenser. [[File:WallSimpleAndNormalMapping.png|centre|vignette|upright=2|Différence sans et avec ''normal-mapping''.]] ===Les ''shaders'' : des programmes exécutés sur le GPU=== Maintenant que nous venons de voir les algorithmes d'éclairages, il est temps de voir comment les réaliser sur une carte graphique. Nous venons de voir qu'il y a une différence entre l'éclairage par pixel et par sommet. Intuitivement, l'éclairage par sommet devrait se faire avec les calculs géométriques, alors que l'éclairage par pixel devrait se faire après avoir appliqué les textures. Les toutes premières cartes graphiques ne géraient ni l'éclairage par sommet, ni l'éclairage par pixel. Elles laissaient les calculs géométriques au CPU. Par la suite, la Geforce 256 a intégré '''circuit de ''Transform & Lightning''''', qui s'occupait de tous les calculs géométriques, éclairage par sommet inclus (d'où le L de T&L). Elle gérait alors l'éclairage par sommet, mais un algorithme particulier, qui n'était pas très flexible. Il ne gérait que des ''material'' bien précis (des ''Phong materials''), rien de plus. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Unité de T&L : géométrie | Rastérisation | Placage de textures | ''Raster Operations Pipeline'' |} L'amélioration suivante est venue sur la Geforce 3 : l'unité de T&L est devenue programmable. Au vu le grand nombre d'algorithmes d'éclairages possibles et le grand nombre de ''materials'' possibles, c'était la seule voie possibles. Les programmeurs pouvaient programmer leurs propres algorithmes d'éclairage par sommet, même s'ils devaient aussi programmer les étapes de transformation et de projection. Mais nous détaillerons cela dans un chapitre dédié sur l'historique des GPUs. Ce qui est important est que la Geforce 3 a introduit une fonctionnalité absolument cruciale pour le rendu 3D moderne : les '''''shaders'''''. Il s'agit de programmes informatiques exécutés par la carte graphique, qui servaient initialement à coder des algorithmes d'éclairage. D'où leur nom : ''shader'' pour ''shading'' (éclairage en anglais). Cependant, l'usage modernes des shaders dépasse le cadre des algorithmes d'éclairage. L'avantage est que cela simplifie grandement l'implémentation des algorithmes d'éclairage. Pas besoin de les intégrer dans la carte graphique pour les utiliser, pas besoin d'un circuit distinct pour chaque algorithme. Sans shaders, si la carte graphique ne gère pas un algorithme d'éclairage, on ne peut pas l'utiliser. A la rigueur, il est parfois possible de l'émuler avec des contournements logiciels, mais au prix de performances souvent désastreuses. Avec des shaders, il est possible de programmer l'algorithme d'éclairage de notre choix, pour l'exécuter sur la carte graphique, avec des performances plus que convenables. [[File:Implémentation de l'éclairage sur les cartes graphiques.png|vignette|Implémentation de l'éclairage sur les cartes graphiques]] Il existe plusieurs types de shaders, mais les deux principaux sont les '''''vertex shaders''''' et les '''''pixel shaders'''''. Les pixels shaders s'occupent de l'éclairage par pixel, leur nom est assez parlent. Les vertex shaders s'occupent de l'éclairage par sommet, mais aussi des étapes de transformation/projection. Je parle bien des trois étapes de transformation vues plus haut, qui effectuent des calculs de transformation de coordonnées avec des matrices. La raison à cela est que les calculs de transformation ressemblent beaucoup aux calculs d'éclairage par sommet. Ils impliquent tous deux des calculs vectoriels, comme des produits scalaires et des produits vectoriels, qui agissent sur des sommets/triangles. Si la carte graphique incorpore un processeur de shader capable de faire de tels calculs, alors il peut servir pour les deux. Pour implémenter les shaders, il a fallu ajouter des processeurs à la carte graphique. Les processeurs en question exécutent les shaders, ils peuvent lire ou écrire dans des textures, mais ne font rien d'autres. Les ''vertex shaders'' font tout ce qui a trait à la géométrie, ils remplacent l'unité de T&L. Les pixels shaders sont entre la rastérisation et les ROPs, ils sont très liés à l'unité de texture. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | rowspan="2" class="f_rouge" | ''Vertex shader'' | rowspan="2" | Rastérisation | Placage de textures | rowspan="2" |''Raster Operations Pipeline'' |- | class="f_rouge" | ''Pixel shader'' |} <noinclude> {{NavChapitre | book=Les cartes graphiques | prev=Les cartes d'affichage des anciens PC | prevText=Les cartes d'affichage des anciens PC | next=Avant les GPUs : les cartes accélératrices 3D | nextText=Avant les GPUs : les cartes accélératrices 3D }}{{autocat}} </noinclude> sfoox0u0oths6z0wte8u0gdmfwrzykk 765165 765164 2026-04-26T20:29:08Z Mewtow 31375 /* Les algorithmes d'éclairage basiques : par triangle, par sommet et par pixel */ Retrait de l'éclairage plat. 765165 wikitext text/x-wiki Le premier jeu à utiliser de la "vraie 3D" texturée fut le jeu Quake, premier du nom. Et depuis sa sortie, la grande majorité des jeux vidéo utilisent de la 3D, même s'il existe encore quelques jeux en 2D. Face à la prolifération des jeux vidéo en 3D, les fabricants de cartes graphiques ont inventé les cartes accélératrices 3D, des cartes vidéo capables d'accélérer le rendu en 3D. Dans ce chapitre, nous allons voir comment elles fonctionnent et comment elles ont évolué dans le temps. Pour comprendre comment celles-ci fonctionnent, il faut faire quelques rapides rappels sur les bases du rendu 3D. ==Les bases du rendu 3D== Une '''scène 3D''' est composée d'un espace en trois dimensions, dans laquelle le moteur d’un jeu vidéo place des objets et les fait bouger. Cette scène est, en première approche, un simple parallélogramme. Un des coins de ce parallélogramme sert d’origine à un système de coordonnées : il est à la position (0, 0, 0), et les axes partent de ce point en suivant les arêtes. Les objets seront placés à des coordonnées bien précises dans ce parallélogramme. ===Les objets 3D et leur géométrie=== <noinclude>[[File:Dolphin triangle mesh.png|vignette|Illustration d'un dauphin, représenté avec des triangles.]]</noinclude> Dans la quasi-totalité des jeux vidéo actuels, les objets et la scène 3D sont modélisés par un assemblage de triangles collés les uns aux autres, ce qui porte le nom de '''maillage''', (''mesh'' en anglais). Il a été tenté dans le passé d'utiliser des quadrilatères (rendu dit en ''quad'') ou d'autres polygones, mais les contraintes techniques ont fait que ces solutions n'ont pas été retenues. [[File:CG WIKI.jpg|centre|vignette|upright=2|Exemple de modèle 3D.]] Les modèles 3D sont définis par leurs sommets, aussi appelés '''vertices''' dans le domaine du rendu 3D. Chaque sommet possède trois coordonnées, qui indiquent sa position dans la scène 3D : abscisse, ordonnée, profondeur. Les sommets sont regroupés en triangles, qui sont formés en combinant trois sommets entre eux. Les anciennes cartes graphiques géraient aussi d'autres formes géométriques, comme des points, des lignes, ou des quadrilatères. Les quadrilatères étaient appelés des ''quads'', et ce terme reviendra occasionnellement dans ce cours. De telles formes basiques, gérées nativement, sont appelées des '''primitives'''. La représentation exacte d'un objet est donc une liste plus ou moins structurée de sommets. La liste doit préciser les coordonnées de chaque sommet, ainsi que comment les relier pour former des triangles. Pour cela, l'objet est représenté par une structure qui contient la liste des sommets, mais aussi de quoi savoir quels sont les sommets reliés entre eux par un segment. Nous en dirons plus dans le chapitre sur le rendu de la géométrie. ===La caméra : le point de vue depuis l'écran=== Outre les objets proprement dit, on trouve une '''caméra''', qui représente les yeux du joueur. Cette caméra est définie au minimum par : * une position ; * par la direction du regard (un vecteur). A la caméra, il faut ajouter tout ce qui permet de déterminer le '''champ de vision'''. Le champ de vision contient tout ce qui est visible à l'écran. Et sa forme dépend de la perspective utilisée. Dans le cas le plus courant dans les jeux vidéos en 3D, il correspond à une '''pyramide de vision''' dont la pointe est la caméra, et dont les faces sont délimitées par les bords de l'écran. A l'intérieur de la pyramide, il y a un rectangle qui représente l'écran du joueur, appelé le '''''viewport'''''. <noinclude>[[File:ViewFrustum.jpg|centre|vignette|upright=2|Caméra.]]</noinclude> [[File:ViewFrustum.svg|vignette|upright=1|Volume délimité par la caméra (''view frustum'').]] La majorité des jeux vidéos ajoutent deux plans : * un ''near plane'' en-deça duquel les objets ne sont pas affichés. Il élimine du champ de vision les objets trop proches. * Un ''far plane'', un '''plan limite''' au-delà duquel on ne voit plus les objets. Il élimine les objets trop lointains. Avec ces deux plans, le champ de vision de la caméra est donc un volume en forme de pyramide tronquée, appelé le '''''view frustum'''''. Le tout est parfois appelée, bien que par abus de langage, la pyramide de vision. Avec d'autres perspectives moins utilisées, le ''view frustum'' est un pavé, mais nous n'en parlerons pas plus dans le cadre de ce cours car elles ne sont presque pas utilisés dans les jeux vidéos actuels. ===Les textures=== Tout objet à rendre en 3D est donc composé d'un assemblage de triangles, et ceux-ci sont éclairés et coloriés par divers algorithmes. Pour rajouter de la couleur, les objets sont recouverts par des '''textures''', des images qui servent de papier peint à un objet. Un objet géométrique est donc recouvert par une ou plusieurs textures qui permettent de le colorier ou de lui appliquer du relief. [[File:Texture+Mapping.jpg|centre|vignette|upright=2|Texture Mapping]] Notons que les textures sont des images comme les autres, codées pixel par pixel. Pour faire la différence entre les pixels de l'écran et les pixels d'une texture, on appelle ces derniers des '''texels'''. Ce terme est assez important, aussi profitez-en pour le mémoriser, nous le réutiliserons dans quelques chapitres. Un autre point lié au fait que les textures sont des images est leur compression, leur format. N'allez pas croire que les textures sont stockées dans un fichier .jpg, .png ou tout autre format de ce genre. Les textures utilisent des formats spécialisés, comme le DXTC1, le S3TC ou d'autres, plus adaptés à leur rôle de texture. Mais qu'il s'agisse d'images normales (.jpg, .png ou autres) ou de textures, toutes sont compressées. Les textures sont compressées pour prendre moins de mémoire. Songez que la compression de texture est terriblement efficace, souvent capable de diviser par 6 la mémoire occupée par une texture. S'en est au point où les textures restent compressées sur le disque dur, mais aussi dans la mémoire vidéo ! Nous en reparlerons dans le chapitre sur la mémoire d'une carte graphique. Plaquer une texture sur un objet peut se faire de deux manières, qui portent les noms de placage de texture inverse et direct. Le placage de texture direct a été utilisé au tout début de la 3D, sur des bornes d'arcade et les consoles de jeu 3DO, PS1, Sega Saturn. De nos jours, on utilise uniquement la technique de placage de texture inverse. Les deux seront décrites dans le détail plus bas. ===La différence entre rastérisation et lancer de rayons=== Les techniques de rendu 3D sont nombreuses, mais on peut les classer en deux grands types : le ''lancer de rayons'' et la ''rasterization''. Sans décrire les deux techniques, sachez cependant que le lancer de rayon n'est pas beaucoup utilisé pour les jeux vidéo. Il est surtout utilisé dans la production de films d'animation, d'effets spéciaux, ou d'autres rendu spéciaux. Dans les jeux vidéos, il est surtout utilisé pour quelques effets graphiques, la rasterization restant le mode de rendu principal. La raison principale est que le lancer de rayons demande beaucoup de puissance de calcul. Une autre raison est que créer des cartes accélératrices pour le lancer de rayons n'est pas simple. Il a existé des cartes accélératrices permettant d'accélérer le rendu en lancer de rayons, mais elles sont restées confidentielles. Les cartes graphiques modernes incorporent quelques circuits pour accélérer le lancer de rayons, mais ils restent d'un usage marginal et servent de compléments au rendu par rastérization. Un chapitre entier sera dédié aux cartes accélératrices de lancer de rayons et nous verrons pourquoi le lancer de rayons est difficile à implémenter avec des performances convenables, ce qui explique que les jeux vidéo utilisent la ''rasterization''. La rastérisation est structurée autour de trois étapes principales : * Une étape purement logicielle, effectuée par le processeur, où le moteur physique calcule la géométrie de la scène 3D. * Une étape de '''traitement de la géométrie''', qui gère tout ce qui a trait aux sommets et triangles. * Une étape de '''rastérisation''' qui détermine sur quels pixels de l'écran est affiché le triangle. * Une étape de '''traitement des pixels''', qui colorie les pixels et gère les textures. Il existe plusieurs rendus différents et la rastérisation ne se fait pas de la même manière selon le rendu utilisé. Il existe des rendus sans textures, d'autres avec, d'autres avec éclairage, d'autres sans, etc. Par contre, l'étape de calcul de la géométrie est la même quel que soit le rendu ! Mieux : le calcul de la géométrie se fait de la même manière entre rastérisation et lancer de rayons, il est le même quelle que soit la technique de rendu 3D utilisée. Les trois étapes précédentes sont réalisées dans des circuits ou processeurs séparés, comme on le verra plus tard. Et cela permet d'utiliser la technique dite du '''pipeline'''. Concrètement, supposons que la carte graphique traite les données par paquets de triangles (en réalité, c'est des paquets de sommets, mais passons). L'étape de traitement de la géométrie peut travailler sur un paquet de triangle, pendant que le paquet précédent est dans l'étape de rastérisation, et que le paquet encore précédent est en train de traiter ses pixels. Cela permet de traiter trois paquets de triangles en même temps, mais à des états d'avancements différents. Mieux que cela : le traitement de la géométrie est lui-même composé d'une succession de sous-étapes, la rasterisation est elle-même découpée en plusieurs sous-étapes, et ainsi de suite. Le nombre d'étapes pour une carte graphique moderne dépasse la dizaine. La rastérisation calcule un rendu 3D avec une suite d'étapes consécutives qui doivent s'enchainer dans un ordre bien précis. L'ensemble de ces étapes est appelé le '''pipeline graphique''', qui sera détaillé dans ce qui suit. ==Le calcul de la géométrie== Le calcul de la géométrie regroupe plusieurs manipulations différentes. La principale demande juste de placer les modèles 3D dans la scène, de placer les objets dans le monde. Puis, il faut centrer la scène 3D sur la caméra. Les deux changements ont pour point commun de demander des changements de repères. Par changement de repères, on veut dire que l'on passe d'un système de coordonnées à un autre. En tout, il existe trois changements de repères distincts qui sont regroupés dans l''''étape de transformation''' : un premier qui place chaque objet 3D dans la scène 3D, un autre qui centre la scène du point de vue de la caméra, et un autre qui corrige la perspective. ===Les trois étapes de transformation=== La première étape place les objets 3D dans la scène 3D. Un modèle 3D est représentée par un ensemble de sommets, qui sont reliés pour former sa surface. Les données du modèle 3D indiquent, pour chaque sommet, sa position par rapport au centre de l'objet qui a les coordonnées (0, 0, 0). La première étape place l'objet 3D à une position dans la scène 3D, déterminée par le moteur physique, qui a des coordonnées (X, Y, Z). Une fois placé dans la scène 3D, le centre de l'objet passe donc des coordonnées (0, 0, 0) aux coordonnées (X, Y, Z) et tous les sommets de l'objet doivent être mis à jour. De plus, l'objet a une certaine orientation : il faut aussi le faire tourner. Enfin, l'objet peut aussi subir une mise à l'échelle : on peut le gonfler ou le faire rapetisser, du moment que cela ne modifie pas sa forme, mais simplement sa taille. En clair, le modèle 3D subit une translation, une rotation et une mise à l'échelle, les trois impliquant une modification des coordonnées des sommets.. [[File:Similarity and congruence transformations.svg|centre|vignette|upright=1.5|Transformations géométriques possibles pour chaque triangle.]] Une fois le placement des différents objets effectué, la carte graphique effectue un changement de coordonnées pour centrer le monde sur la caméra. Au lieu de considérer un des bords de la scène 3D comme étant le point de coordonnées (0, 0, 0), il va passer dans le référentiel de la caméra. Après cette transformation, le point de coordonnées (0, 0, 0) sera la caméra. La direction de la vue du joueur sera alignée avec l'axe de la profondeur (l'axe Z). [[File:View transform.svg|centre|vignette|upright=2|Étape de transformation dans un environnement en deux dimensions : avant et après. On voit que l'on centre le monde sur la position de la caméra et dans sa direction.]] Enfin, il faut aussi corriger la perspective, ce qui est le fait de l'étape de projection, qui modifie la forme du ''view frustum'' sans en modifier le contenu. Différents types de perspective existent et celles-ci ont un impact différent les unes des autres sur le ''view frustum''. Dans le cas qui nous intéresse, le ''view frustum'' passe d’une forme de trapèze tridimensionnel à une forme de pavé dont l'écran est une des faces. ===Les changements de coordonnées se font via des multiplications de matrices=== Les trois étapes précédentes demande de faire des changements de coordonnées, chaque sommet voyant ses coordonnées remplacées par de nouvelles. Or, un changement de coordonnée s'effectue assez simplement, avec des matrices, à savoir des tableaux organisés en lignes et en colonnes avec un nombre dans chaque case. Un changement de coordonnées se fait simplement en multipliant le vecteur (X, Y, Z) des coordonnées d'un sommet par une matrice adéquate. Il existe des matrices pour la translation, la mise à l'échelle, d'autres pour la rotation, une autre pour la transformation de la caméra, une autre pour l'étape de projection, etc. Un changement de coordonnée s'effectue assez simplement en multipliant le vecteur-coordonnées (X, Y, Z) d'un sommet par une matrice adéquate. Un petit problème est que les matrices qui le permettent sont des matrices avec 4 lignes et 4 colonnes. Or, la multiplication demande que le nombre de coordonnées du vecteur soit égal au nombre de colonnes. Pour résoudre ce petit problème, on ajoute une 4éme coordonnée aux sommets, la coordonnée homogène, qui ne sert à rien, et est souvent mise à 1, par défaut. Mais oublions ce détail. Il se trouve que multiplier des matrices amène certaines simplifications. Au lieu de faire plusieurs multiplications de matrices, il est possible de fusionner les matrices en une seule, ce qui permet de simplifier les calculs. Ce qui fait que le placement des objets, changement de repère pour centrer la caméra, et d'autres traitements forts différents sont regroupés ensemble. Le traitement de la géométrie implique, sans surprise, des calculs de géométrie dans l'espace. Et cela implique des opérations mathématiques aux noms barbares : produits scalaires, produits vectoriels, et autres calculs impliquant des vecteurs et/ou des matrices. Et les calculs vectoriels/matriciels impliquent beaucoup d'additions, de soustractions, de multiplications, de division, mais aussi des opérations plus complexes : calculs trigonométriques, racines carrées, inverse d'une racine carrée, etc. Au final, un simple processeur peut faire ce genre de calculs, si on lui fournit le programme adéquat, l'implémentation est assez aisée. Mais on peut aussi implémenter le tout avec un circuit spécialisé, non-programmable. Les deux solutions sont possibles, tant que le circuit dispose d'assez de puissance de calcul. Les cartes graphiques anciennes contenaient un ou plusieurs circuits de multiplication de matrices spécialisés dans l'étape de transformation. Chacun de ces circuits prend un sommet et renvoie le sommet transformé. Ils sont composés d'un gros paquet de multiplieurs et d'additionneurs flottants. Pour plus d'efficacité, les cartes graphiques comportent plusieurs de ces circuits, afin de pouvoir traiter plusieurs sommets en même temps. ==L'élimination des surfaces cachées== Un point important du rendu 3D est que ce que certaines portions de la scène 3D ne sont pas visibles depuis la caméra. Et idéalement, les portions de la scène 3D qui ne sont pas visibles à l'écran ne doivent pas être calculées. A quoi bon calculer des choses qui ne seront pas affichées ? Ce serait gâcher de la puissance de calcul. Et pour cela, de nombreuses optimisations visent à éliminer les calculs inutiles. Elles sont regroupées sous les termes de '''''clipping''''' ou de '''''culling'''''. La différence entre ''culling'' et ''clipping'' n'est pas fixée et la terminologie n'est pas claire. Dans ce qui va suivre, nous n'utiliserons que le terme ''culling''. Les cartes graphiques modernes embarquent diverses méthodes de ''culling'' pour abandonner les calculs quand elles s’aperçoivent que ceux-ci portent sur une partie non-affichée de l'image. Cela fait des économies de puissance de calcul assez appréciables et un gain en performance assez important. Précisons que le ''culling'' peut être plus ou moins précoce suivant le type de rendu 3D utilisé, mais nous verrons cela dans la suite du chapitre. ===Les différentes formes de ''culling''/''clipping''=== La première forme de ''culling'' est le '''''view frustum culling''''', dont le nom indique qu'il s'agit de l'élimination de tout ce qui est situé en-dehors du ''view frustum''. Ce qui est en-dehors du champ de vision de la caméra n'est pas affiché à l'écran n'est pas calculé ou rendu, dans une certaine mesure. Le ''view frustum culling'' est assez trivial : il suffit d'éliminer ce qui n'est pas dans le ''view frustum'' avec quelques calculs de coordonnées assez simples. Quelques subtilités surviennent quand un triangle est partiellement dans le ''view frustrum'', ce qui arrive parfois si le triangle est sur un bord de l'écran. Mais rien d'insurmontable. [[File:View frustum culling.svg|centre|vignette|upright=1|''View frustum culling'' : les parties potentiellement visibles sont en vert, celles invisibles en rouge et celles partiellement visibles en bleu.]] Les autres formes de ''culling'' visent à éliminer ce qui est dans le ''view frustum'', mais qui n'est pas visible depuis la caméra. Pensez à des objets cachés par un autre objet plus proche, par exemple. Ou encore, pensez aux faces à l'arrière d'un objet opaque qui sont cachées par l'avant. Ces deux cas correspondent à deux types de ''culling''. L'élimination des objets masqués par d'autres est appelé l'''occlusion culling''. L'élimination des parties arrières d'un objet est appelé le ''back-face culling''. Dans les deux cas, nous parlerons d''''élimination des surfaces cachées'''. [[File:Occlusion culling example PL.svg|centre|vignette|''Occlusion culling'' : les objets en bleu sont visibles, ceux en rouge sont masqués par les objets en bleu.]] Le lancer de rayons n'a pas besoin d'éliminer les surfaces cachées, il ne calcule que les surfaces visibles. Par contre, la rastérisation demande d'éliminer les surfaces cachées. Sans cela, le rendu est incorrect dans le pire des cas, ou alors le rendu calcule des surfaces invisibles pour rien. Il existe de nombreux algorithmes logiciels pour implémenter l'élimination des surfaces cachées, mais la carte graphique peut aussi s'en charger. L'''occlusion culling'' demande de connaitre la distance à la caméra de chaque triangle. La distance à la caméra est appelée la '''profondeur''' du triangle. Elle est déterminée à l'étape de rastérisation et est calculée à chaque sommet. Lors de la rastérisation, chaque sommet se voit attribuer trois coordonnées : deux coordonnées x et y qui indiquent sa position à l'écran, et une coordonnée de profondeur notée z. ===L'algorithme du peintre=== Pour éliminer les surfaces cachées, la solution la plus simple consiste simplement à rendre les triangles du plus lointain au plus proche. L'idée est que si deux triangles se recouvrent totalement ou partiellement, on doit dessiner celui qui est derrière, puis celui qui est devant. Le dessin du second va recouvrir le premier. Quelque chose qui devrait vous rappeler le rendu 2D, où les sprites sont rendus du plus lointain au plus proche. Il ne s'agit ni plus ni moins que de l''''algorithme du peintre'''. [[File:Polygons cross.svg|vignette|Polygons cross]] Un problème est que la solution ne marche pas avec certaines configurations particulières, dans le cas où des polygones un peu complexes se chevauchent plusieurs fois. Il se présente rarement dans un rendu 3D normal, mais c'est quand même un cas qu'il faut gérer. Le problème est suffisant pour que cette solution ne soit plus utilisée dans le rendu 3D normal. Un autre problème est que l'algorithme demande de trier les triangles d'une scène 3D selon leur profondeur, du plus profond au moins profond. Et les cartes graphiques n'aiment pas ça, que ce soit les anciennes cartes graphiques comme les modernes. Il s'agit généralement d'une tâche qui est réalisée par le processeur, le CPU, qui est plus efficace que le GPU pour trier des trucs. Aussi, l'algorithme du peintre était utilisé sur d'anciennes cartes graphiques, qui ne géraient pas la géométrie mais seulement les textures et quelques effets de post-processing. Avec ces GPU, les jeux vidéo calculaient la géométrie et la triait sur le CPU, puis effectuaient le reste de la rastérisation sur le GPU. Les anciens jeux en 2.5D comme DOOM ou les DOOM-like, utilisaient une amélioration de l'algorithme du peintre. L'amélioration variait suivant le moteur de jeu utilisé, et donnait soit une technique dite de ''portal rendering'', soit un système de ''Binary Space Partionning'', assez complexes et difficiles à expliquer. Mais il ne s'agissait pas de jeux en 3D, les maps de ces jeux avaient des contraintes qui rendaient cette technique utilisable. Ils n'avaient pas de polygones qui se chevauchent, notamment. ===Le tampon de profondeur=== [[File:Z-buffer no text.jpg|vignette|Z-buffer correspondant à un rendu]] Une autre solution utilise ce qu'on appelle un '''tampon de profondeur''', aussi appelé un ''z-buffer''. Il s'agit d'un tableau, stocké en mémoire vidéo, qui mémorise la coordonnée z de l'objet le plus proche pour chaque pixel. Par défaut, ce tampon de profondeur est initialisé avec la valeur de profondeur maximale, celle du ''far plane'' du ''viewfrustum''. Au fur et à mesure que les objets seront calculés, le tampon de profondeur est mis à jour, conservant ainsi la trace de l'objet le plus proche de la caméra. Si jamais un triangle a une coordonnée z plus grande que celle du tampon de profondeur, cela veut dire qu'il est situé derrière un objet déjà rendu. Il est éliminé (sauf si transparence il y a) et le tampon de profondeur n'a pas à être mis à jour. Dans le cas contraire, l'objet est plus près de la caméra et sa coordonnée z remplace l'ancienne valeur z dans le tampon de profondeur. [[File:Z-buffer.svg|centre|vignette|upright=2.0|Illustration du processus de mise à jour du Z-buffer.]] Il existe des techniques alternatives pour coder la coordonnée de profondeur, qui se distinguent par le fait que la coordonnée z n'est pas proportionnelle à la distance entre le fragment et la caméra. Mais il s'agit là de détails assez mathématiques que je me permets de passer sous silence. Dans la suite de ce cours, nous allons juste parler de profondeur pour regrouper toutes ces techniques, conventionnelles ou alternatives. Toutes les cartes graphiques modernes utilisent un système de ''z-buffer''. C'est la seule solution pour avoir des performances dignes de ce nom. Il faut cependant noter qu'elles utilisent des tampons de profondeur légèrement modifiés, qui ne mémorisent pas la coordonnée de profondeur, mais une valeur dérivée. Pour simplifier, ils ne mémorisent pas la coordonnée de profondeur z, mais son inverse 1/z. Les raisons à cela ne peuvent pas encore être expliquées à ce moment du cours, aussi nous allons simplement dire que c'est une histoire de correction de perspective. Les coordonnées z et 1/z sont codées sur quelques bits, allant de 16 bits pour les anciennes cartes graphiques, à 24/32 bits pour les cartes plus récentes. De nos jours, les Z-buffer de 16 bits sont abandonnés et toutes les cartes graphiques utilisent des coordonnées z de 24 à 32 bits. La raison est que les Z-buffer de 16 bits ont une précision insuffisante, ce qui fait que des artefacts peuvent survenir. Si deux objets sont suffisamment proches, le tampon de profondeur n'a pas la précision suffisante pour discriminer les deux objets. Pour lui, les deux objets sont à la même place. Conséquence : il faut bien choisir un des deux objets et ce choix se fait pixel par pixel, ce qui fait des artefacts visuels apparaissent. On parle alors de '''''z-fighting'''''. Voici ce que cela donne : [[File:Z-fighting.png|centre|vignette|Z-fighting]] Un défaut du tampon de profondeur est qu'il ne gère pas correctement les objets transparents. Dès que de la transparence est présente dans une scène 3D, le tampon de profondeur ne peut pas être utilisé. Une solution pour cela est de rendre une scène 3D en deux phases : une pour les objets opaques, une avec les objets transparents. La où on rend les objets opaques utilise le tampon de profondeur, mais il est désactivé lors de la seconde. ==La rastérisation et les textures== Dans cette section, nous allons voir ensemble l'étape de rastérisation et l'étape de traitement des pixels. La rastérisation est difficile à expliquer, surtout que son rôle exact dépend de la technique de rendu utilisée. Pour simplifier, elle projette un rendu en 3D sur un écran en 2D. Une autre explication tout aussi vague est qu'elle s'occupe la traduction des triangles en un affichage pixelisé à l'écran. Elle détermine à quoi ressemble la scène visible sur l'écran. C'est par exemple lors de cette étape que sont appliquées certaines techniques de ''culling'', qui éliminent les portions non-visibles de l'image, ainsi qu'une correction de la perspective et diverses opérations d'interpolation dont nous parlerons dans plusieurs chapitres. La rastérisation et placage de textures sont deux opérations très liées entre elles. Il existe deux manières principales pour lier les textures à la géométrie : la méthode directe et la méthode inverse (''UV Mapping''). Et les deux font que la rastérisation se fait de manière très différente. Précisons cependant que les rendus les plus simples n'utilisent pas de textures du tout. Ils se contentent de colorier les triangles, voire d'un simple rendu en fil de fer basé sur du tracé de lignes. Dans la suite de cette section, nous allons voir les quatre types de rendu principaux : le rendu en fils de fer, le rendu colorié, et deux rendus utilisant des textures. ===Le rendu en fil de fer=== [[File:Obj lineremoval.png|vignette|Rendu en fil de fer d'un objet 3D.]] Le '''rendu 3D en fils de fer''' est illustré ci-contre. Il s'agit d'un rendu assez ancien, utilisé au tout début de la 3D, sur des machines qu'on aurait du mal à appeler ordinateurs. Il se contente de tracer des lignes à l'écran, lignes qui connectent deux sommets, qui ne sont autres que les arêtes de la géométrie de la scène rendue. Le tout était suffisant pour réaliser quelques jeux vidéos rudimentaires. Les tout premiers jeux vidéos utilisaient ce rendu, l'un d'entre eux étant Maze War, le tout premier FPS. {| |[[File:Maze war.jpg|vignette|Maze war]] |[[File:Maze representation using wireframes 2022-01-10.gif|centre|vignette|Maze representation using wireframes 2022-01-10]] |} Le monde est calculé en 3D, il y a toujours un calcul de la géométrie, la scène est rastérisée normalement, les portions invisbles de l'image sont retirées, mais il n'y a pas d'application de textures après rastérisation. A la place, un algorithme de tracé de ligne trace les lignes à l'écran. Quand un triangle passe l'étape de rastérisation, l'étape de rastérisation fournit la position des trois sommets sur l'écran. En clair, elle fournit les coordonnées de trois pixels, un par sommet. A la suite, un algorithme de tracé de ligne trace trois lignes, une par paire de sommet. L'implémentation demande juste d'avoir une unité de calcul géométrique, une unité de rastérisation, et un VDC qui supporte le tracé de lignes. Elle est donc assez simple et ne demande pas de circuits de gestion des textures ni de ROP. Le VDC écrit directement dans le ''framebuffer'' les lignes à tracer. Il a existé des proto-cartes graphiques spécialisées dans ce genre de rendu, comme le '''''Line Drawing System-1''''' de l'entreprise Eans & Sutherland. Nous détaillerons son fonctionnement dans quelques chapitres. ===Le rendu à primitives colorées=== [[File:MiniFighter.png|vignette|upright=1|Exemple de rendu pouvant être obtenu avec des sommets colorés.]] Une amélioration du rendu précédent utilise des triangles/''quads'' coloriés. Chaque triangle ou ''quad'' est associé à une couleur, et cette couleur est dessinée sur le triangle/''quad''après la rastérisation. Le rendu est une amélioration du rendu en fils de fer. L'idée est que chaque triangle/''quad'' est associé à une couleur, qui est dessinée sur le triangle/''quad'' après la rastérisation. La technique est nommée ''colored vertices'' en anglais, nous parlerons de '''rendu à maillage coloré'''. [[File:Malla irregular de triángulos modelizando una superficie convexa.png|centre|vignette|upright=2|Maillage coloré.]] La couleur est propagée lors des calculs géométriques et de la rastérisation, sans subir de modifications. Une fois un rendu en fils de fer effectué, la couleur du triangle est récupérée. Le triangle/''quad'' rendu correspond à un triangle/''quad'' à l'écran. Et l'intérieur de ce triangle/''quad'' est colorié avec la couleur transmise. Pour cela, on utilise encore une fois une fonction du VDC : celle du remplissage de figure géométrique. Nous l’avions vu en parlant des VDC à accélération 2D, mais elle est souvent prise en charge par les ''blitters''. Ils peuvent remplir une figure géométrique avec une couleur unique, on réutilise cette fonction pour colorier le triangle/''quad''. L'étape de rastérisation fournit les coordonnées des sommets de la figure géométrique, le ''blitter'' les utilise pour colorier la figure géométrique. Niveau matériel, quelques bornes d'arcade ont utilisé ce rendu. La toute première borne d'arcade utilisant le rendu à maillage coloré est celle du jeu I Robot, d'Atari, sorti en 1983. Par la suite, dès 1988, les cartes d'arcades Namco System 21 et les bornes d'arcades Sega Model 1 utilisaient ce genre de rendu. On peut s'en rendre compte en regardant les graphismes des jeux tournant sur ces bornes d'arcade. Des jeux comme Virtua Racing, Virtua Fighter ou Virtua Formula sont assez parlants à ce niveau. Leurs graphismes sont assez anguleux et on voit qu'ils sont basés sur des triangles uniformément colorés. Pour ceux qui veulent en savoir plus sur la toute première borne d'arcade en rendu à maillage colorée, la borne ''I Robot'' d'Atari, voici une vidéo youtube à ce sujet : * [https://www.youtube.com/watch?v=6miEkPENsT0 I Robot d'Atari, le pionnier de la 3D Flat.] ===Le placage de textures direct=== Les deux rendus précédents sont très simples, mais n'utilisent pas de textures. Et il est temps de voir les deux rendus qui utilisent des textures. Il y en a deux types, appelés rendu avec placage de texture direct et indirect, nous allons voir le '''rendu par placage de texture direct''' en premier. Et nous l'appellerons ''rendu direct'' dans ce qui suit, pour simplifier les explications. L'idée est assez simple et peut utiliser aussi bien des triangles que des ''quads'', mais nous allons partir du principe qu'elle utilise des '''''quads''''', à savoir que les objets 3D sont composés de quadrilatères. Lorsqu'un ''quad'' est rastérisé, sa forme à l'écran est un rectangle déformé par la perspective. On obtient un rectangle si le ''quad'' est vu de face, un trapèze si on le voit de biais. Et le ''sprite'' doit être déformé de la même manière que le ''quad''. L'idée est que tout quad est associé à une texture, à un sprite. La figure géométrique qui correspond à un ''quad'' à l'écran est remplie non pas par une couleur uniforme, mais par un ''sprite'' rectangulaire. Il suffit techniquement de recopier le ''sprite'' à l'écran, c'est à dire dans la figure géométrique, au bon endroit dans le ''framebuffer''. Le rendu direct est en effet un intermédiaire entre rendu 2D à base de ''sprite'' et rendu 3D moderne. La géométrie est rendue en 3D pour générer des ''quads'', mais ces ''quads'' ne servent à guider la copie des sprites/textures dans le ''framebuffer''. [[File:TextureMapping.png|centre|vignette|upright=2|Exemple caricatural de placage de texture sur un ''quad''.]] La subtilité est que le sprite est déformé de manière à rentrer dans un quadrilatère, qui n'est pas forcément un rectangle à l'écran, mais est déformé par la perspective et son orientation en 3D. Le sprite doit être déformé de deux manières : il doit être agrandi/réduit en fonction de la taille de la figure affichée à l'écran, tourné en fonction de l'orientation du ''quad'', déformé pour gérer la perspective. Pour cela, il faut connaitre les coordonnées de profondeur de chaque bord d'un ''quad'', et de faire quelques calculs. N'importe quel VDC incluant un ''blitter'' avec une gestion du zoom/rotation des sprites peut le faire. : Si on veut avoir de beaux graphismes, il vaut mieux appliquer un filtre pour lisser le sprite envoyé dans le trapèze, filtre qui se résume à une opération d'interpolation et n'est pas très différent du filtrage de texture qui lisse les textures à l'écran. Un autre point est que les ''quads'' doivent être rendus du plus lointain au plus proche. Sans cela, on obtient rapidement des erreurs de rendu. L'idée est que si deux quads se chevauchent, on doit dessiner celui qui est derrière, puis celui qui est devant. Le dessin du second va recouvrir le premier. L'écriture du sprite du second quad écrasera les données du premier quad, pour les portions recouvertes, lors de l'écriture du sprite dans le ''framebuffer''. Quelque chose qui devrait vous rappeler le rendu 2D, où les sprites sont rendus du plus lointain au plus proche. Le rendu inverse utilise très souvent des triangles pour la géométrie, alors que le rendu direct a tendance à utiliser des ''quads'', mais il ne s'agit pas d'une différence stricte. L'usage de triangles/''quads'' peut se faire aussi bien avec un rendu direct comme avec un rendu inverse. Cependant, le rendu en ''quad'' se marie très bien au rendu direct, alors que le rendu en triangle colle mieux au rendu inverse. L'avantage de cette technique est qu'on parcourt les textures dans un ordre bien précis. Par exemple, on peut parcourir la texture ligne par ligne, l'exploiter par blocs de 4*4 pixels, etc. Et accéder à une texture de manière prédictible se marie bien avec l'usage de mémoires caches, ce qui est un avantage en matière de performances. Mais un même pixel du ''framebuffer'' est écrit plusieurs fois quand plusieurs quads se superposent, alors que le rendu inverse gère la situation avec une seule écriture (sauf si usage de la transparence). De plus, la gestion de la transparence était compliquée et les jeux devaient ruser en utilisation des solutions logicielles assez complexes. Niveau implémentation matérielle, une carte graphique en rendu direct demande juste trois circuits. Le premier est un circuit de calcul géométrique, qui rend la scène 3D. Le tri des quads est souvent réalisé par le processeur principal, et non pas par un circuit séparé. Toutes les étapes au-delà de l'étape de rastérisation étaient prises en charge par un VDC amélioré, qui écrivait des sprites/textures directement dans le ''framebuffer''. {|class="wikitable" |- ! Géométrie | Processeurs dédiés programmé pour émuler le pipeline graphique |- ! Tri des quads du plus lointain au plus proche | Processeur principal (implémentation logicielle) |- ! Application des textures | ''Blitter'' amélioré, capable de faire tourner et de zoomer sur des ''sprites''. |} L'implémentation était très simple et réutilisait des composants déjà existants : des VDC 2D pour l'application des textures, des processeurs dédiés pour la géométrie. Les unités de calcul de la géométrie étaient généralement implémentées avec un ou plusieurs processeurs dédiés. Vu qu'on savait déjà effectuer le rendu géométrique en logiciel, pas besoin de créer un circuit sur mesure. Il suffisait de dédier un processeur spécialisé rien que pour les calculs géométriques et on lui faisait exécuter un code déjà bien connu à la base. En clair, ils utilisaient un code spécifique pour émuler un circuit fixe. C'était clairement la solution la plus adaptée pour l'époque. Les unités géométriques étaient des processeurs RISC, normalement utilisés dans l'embarqué ou sur des serveurs. Elles utilisaient parfois des DSP. Pour rappel, les DSP des processeurs de traitement de signal assez communs, pas spécialement dédiés aux rendu 3D, mais spécialisé dans le traitement de signal audio, vidéo et autre. Ils avaient un jeu d'instruction assez proche de celui des cartes graphiques actuelles, et supportaient de nombreuses instructions utiles pour le rendu 3D. Le rendu direct a été utilisé dans la période de transition entre rendu 2D et rendu 3D, car il était très adapté pour faire cette transition. Coupler un VDC à un processeur pour la géométrie était particulièrement simple à l'époque. Le rendu direct a été utilisé sur des bornes d'arcade dès les années 90. Outre les bornes d'arcade, quelques consoles de 5ème génération utilisaient le rendu direct, avec les mêmes solutions matérielles. La géométrie était calculée sur plusieurs processeurs dédiés. Le reste du pipeline était géré par un VDC 2D qui implémentait le placage de textures. Deux consoles étaient dans ce cas : la 3DO, et la Sega Saturn. Le rendu direct est aujourd'hui abandonné. ===Le placage de textures inverse=== Le rendu précédent, le rendu direct, permet d'appliquer des textures directement dans le ''framebuffer''. Mais comme dit plus haut, il existe une seconde technique pour plaquer des textures, appelé le '''placage de texture inverse''', aussi appelé l'''UV Mapping''. Elle associe une texture complète pour un modèle 3D,contrairement au placage de tecture direct qui associe une texture par ''quad''/triangle. L'idée est que l'on attribue un texel à chaque sommet. Plus précisémment, chaque sommet est associé à des '''coordonnées de texture''', qui précisent quelle texture appliquer, mais aussi où se situe le texel à appliquer dans la texture. Par exemple, la coordonnée de texture peut dire : je veux le pixel qui est à ligne 5, colonne 27 dans cette texture. La correspondance entre texture et géométrie est réalisée lorsque les créateurs de jeu vidéo conçoivent le modèle de l'objet. [[File:Texture Mapping example.png|centre|vignette|upright=2|Exemple de placage de texture.]] Dans les faits, on n'utilise pas de coordonnées entières de ce type, mais deux nombres flottants compris entre 0 et 1. La coordonnée 0,0 correspond au texel en bas à gauche, celui de coordonnée 1,1 est tout en haut à droite. L'avantage est que ces coordonnées sont indépendantes de la résolution de la texture, ce qui aura des avantages pour certaines techniques de rendu, comme le ''mip-mapping''. Les deux coordonnées de texture sont notées u,v avec DirectX, ou encore s,t dans le cas général : u est la coordonnée horizontale, v la verticale. [[File:UVMapping.png|centre|vignette|upright=2|UV Mapping]] Avec le placage de texture inverse, la rastérisation se fait grosso-modo en trois étapes : la rastérisation proprement dite, le placage de textures, et les opérations finales qui écrivent un pixel dans le ''framebuffer''. Au niveau du matériel, ainsi que dans la plupart des API 3D, les trois étapes sont réalisées par des circuits séparés. [[File:01 3D-Rasterung-a.svg|vignette|Illustration du principe de la rasterization. La surface correspondant à l'écran est subdivisée en pixels carrés, de coordonnées x et y. La caméra est placée au point e. Pour chaque pixel, on trace une droite qui part de la caméra et qui passe par le pixel considéré. L'intersection entre une surface et cette droite se fait en un point, appartenant à un triangle.]] Lors de la rasterisation, chaque triangle se voit attribuer un ou plusieurs pixels à l'écran. Pour bien comprendre, imaginez une ligne droite qui part de caméra et qui passe par un pixel sur le plan de l'écran. Cette ligne intersecte 0, 1 ou plusieurs objets dans la scène 3D. Les triangles situés ces intersections entre cette ligne et les objets rencontrés seront associés au pixel correspondant. L'étape de rastérisation prend en entrée un triangle et renvoie la coordonnée x,y du pixel associé. Il s'agit là d'une simplification, car un triangle tend à occuper plusieurs pixels sur l'écran. L'étape de rastérisation fournit la liste de tous les pixels occupés par un triangle, et les traite un par un. Quand un triangle est rastérisé, le rasteriseur détermine la coordonnée x,y du premier pixel, applique une texture dessus, puis passe au suivant, et rebelote jusqu'à ce que tous les pixels occupés par le triangles aient été traités. L'implémentation matérielle du placage de texture inverse est beaucoup plus complexe que pour les autres techniques. Pour être franc, nous allons passer le reste du cours à parler de l'implémentation matérielle du placage de texture inverse, ce qui prendra plus d'une dizaine de chapitres. ==La transparence, les fragments et les ROPs== Dans ce qui suit, nous allons parler uniquement de la rastérisation avec placage de textures inverse. Les autres formes de rastérisation ne seront pas abordées. La raison est que tous les GPUs modernes utilisent cette forme de rastérisation, les exceptions étant rares. De même, ils utilisent un tampon de profondeur, pour l'élimination des surfaces cachées. La rastérisation effectue donc des calculs géométriques, suivis d'une étape de rastérisation, puis de placage des textures. Ces trois étapes sont réalisées par une unité géométrique, une unité de rastérisation, et un circuit de placage de textures. Du moins sur le principe, car les cartes graphiques modernes ont fortement optimisé l'implémentation et n'ont pas hésité à fusionner certains circuits. Mais nous verrons cela en temps voulu, nous n'allons pas résumer plusieurs décennies d'innovation technologique en quelques paragraphes. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Placage de textures |} Mais où mettre le tampon de profondeur ? Intuitivement, on se dit qu'il vaut mieux faire l'élimination des surfaces cachées le plus tôt possible, dès que la coordonnée de profondeur est connue. Et elle est connu à l'étape de rastérisation, une fois les sommets transformés. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Tampon de profondeur | Placage de textures |} En réalité, la profondeur des fragments est gérée par un circuit appelé le '''''Raster Operations Pipeline''''' (ROP), situé à la toute fin du pipeline graphique. Dans ce qui suit, nous utiliserons l'abréviation ROP pour simplifier les explications. Le ROP effectue quelques traitements sur les fragments, avant d'enregistrer l'image finale dans la mémoire vidéo. Il est placé à la fin du pipeline pour gérer correctement la transparence. Et nous allons voir pourquoi la transparence est gérée à la fin du pipeline. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Géométrie | Rastérisation | Placage de textures | ''Raster Operations Pipeline'' |} ===Le mélange ''alpha''=== La transparence se manifeste quand plusieurs objets sont l'un derrière l'autre. Histoire de simplifier les explications, nous allons d'abord voir le cas où un objet semi-transparent est devant un objet opaque. La couleur perçue est alors un mélange de la couleur de l'objet opaque et celle de l'objet semi-transparent. Le mélange dépend d'à quel point l'objet semi-transparent est transparent. Avec un objet parfaitement transparent, seul l'objet opaque est visible. Avec un objet à moitié transparent, la couleur finale sera pour moitié celle de l'objet opaque, pour moitié celle de l'objet semi-transparent. Et c'est pareil pour les cas intermédiaires entre un objet totalement transparent et un objet totalement opaque. La transparence d'un objet/pixel est définie par un nombre, appelé la '''composante ''alpha'''''. Plus la composante alpha est élevée, plus le pixel est opaque. Elle vaut 0 pour un objet opaque et 1 pour un objet transparent. Elle est ajoutée aux composantes RGB, ce qui fait que tout fragment contient une "couleur de transparence" en plus des couleurs RGB. Elle agit comme un coefficient qui dit comment mélanger la couleur d'un objet transparent et d'un objet opaque. Le calcul de la transparence est une moyenne pondérée par la composante alpha. On parle alors d''''''alpha blending'''''. : <math>\text{Couleur finale} = \alpha \times \text{Couleur de l'objet transparent} + (1 - \alpha) \times \text{Couleur de l'objet opaque}</math> [[File:Texture splatting.png|centre|vignette|upright=2.0|Calcul de transparence. La première ligne montre le produit pour l'objet transparent, la seconde ligne est celle de l'objet opaque. La troisième ligne est celle de l'addition finale.]] Maintenant, qu'en est-il du cas où plusieurs objets sont superposés ? Si vous tracez une demi-droite dont l'origine est la caméra et qui passe par le pixel, il arrive qu'elle intersecte la géométrie en plusieurs points, un point par objet sur la ligne du regarde. Sans transparence, l'objet le plus proche cache tous les autres et c'est donc lui qui décide de la couleur du pixel. Mais avec un objet transparent, la couleur finale est un mélange de la couleur de plusieurs points d'intersection. Il faut donc calculer un pseudo-pixel pour chaque point d'intersection, auquel on donne le nom de '''fragment'''. Un fragment possède une position à l'écran, une coordonnée de profondeur, une couleur, ainsi que quelques autres informations potentiellement utiles. Les fragments attribués à un même pixel, qui sont à la même position sur l'écran, sont combinés pour obtenir la couleur finale de ce pixel. Il est possible d'utiliser le mélange ''alpha'' pour cela. Il suffit de faire le mélange ''alpha'' entre le fragment qui vient d'être calculé, et le pixel dans le ''framebuffer''. Pour cela, le fragment a une composante ''alpha'', qui est ajouté aux trois couleurs RGB. Le pixel déjà dans le ''framebuffer'' est un résultat temporaire, né du mélange ''alpha'' de tous les fragments précédents. Un défaut de cette méthode est qu'elle fonctionne assez mal avec un tampon de profondeur. Si le tampon de profondeur est activé, le mélange ''alpha'' ne fonctionne que si les objets sont rendus du plus lointain au plus proche. Et procéder dans cet ordre a un défaut : on dessine des objets dans le ''framebuffer'', pour qu'ensuite les objets devant écrasent ce qui a déjà été dessiné. Un même pixel peut donc être dessiné plusieurs fois, dont une seule sera pertinente. Et ces écritures utilisent de la bande passante mémoire, qui est une ressource précieuse sur un GPU moderne. Il s'agit d'un phénomène appelé '''''overdraw''''', ou sur-dessinage en français. Quelques optimisations permettent d'éliminer l'''overdraw'' en rendant les objets du plus proche au plus lointain, d'autres permettent de dessiner des objets dans un ordre arbitraire, mais nous ne pouvons pas en parler ici. Beaucoup de moteurs 3D rendent séparément les objets opaques et transparents. Une première passe rend les objets opaques, puis les objets transparents sont rendus dans une seconde passe. Les objets opaques sont rendus dans le désordre, ce qui fait qu'on n'a pas à les trier, alors que les objets transparents doivent être triés selon leur distance. un autre avantage est que le mélange ''alpha'' est désactivé lors de la première passe, alors que c'est la mise à jour du tampon de profondeur qui est désactivé lors de la seconde passe, ce qui augmente un peu les performances dans les deux cas. ===Le test ''alpha''=== Le test ''alpha'' est une technique qui permet d'annuler le rendu d'un fragment en fonction de sa transparence. Si la composante alpha est en-dessous ou au-dessus d'un seuil, le fragment est simplement abandonné. Le seuil en question est configurable, de même que la comparaison utilisée : on peut éliminer le fragment si sa transparence est au-dessus d'un certain seuil, en-dessous, égal, différent, etc. Il s'agit d'une optimisation qui est utile dans certains scénarios spécifiques. Par exemple, si l'objet a une transparence très élevée, du genre 95%, autant le compter comme complétement transparent, afin d'éviter des opérations de mélange ''alpha''. En effet, les opérations de mélange ''alpha'' sont très lentes, car elles demandent de faire des opérations de lecture-écriture en mémoire vidéo : on lit un pixel dans le ''framebuffer'', on applique le mélange ''alpha'' et on écrit le résultat en mémoire vidéo. L'''alpha test'' permet donc de gagner en performance au prix d'une baisse de la qualité d'image. Il y a cependant des cas où l'usage du test ''alpha'' est primordial, au-delà d'une question de performances. Un exemple classique est celui du rendu du feuillage dans un jeu 3D. Un feuillage est composé en assemblant plusieurs images de feuilles. Chaque feuille est un carré sur lequel on place une texture de feuille, qui est opaque pour la partie verte des feuilles, transparente pour le reste. Les carrés ne sont cependant pas superposés, mais s'intersectent fortement, ce qui fait que le mélange ''alpha'' ne donne pas de bons résultats. L'usage du test ''alpha'' permet d'obtenir un rendu correct. Pour d'informations via ce lien : * [https://bgolus.medium.com/anti-aliased-alpha-test-the-esoteric-alpha-to-coverage-8b177335ae4f Anti-aliased Alpha Test: The Esoteric Alpha To Coverage]. ===Les effets de brouillard=== Les '''effets de brouillard''' sont nécessaires dans certains jeux vidéo pour l'ambiance (pensez à des jeux d'horreur comme Silent Hill), mais ils ont surtout été utilisés pour économiser des calculs. L'idée est de ne pas calculer les graphismes au-delà d'une certaine distance, sans que cela se voie. Le ''view frustum'' utilise alors un plan limite, au-delà duquel on ne voit pas les objets. Mais ce plan limite donne une cassure inesthétique dans le rendu. Pour masquer cette cassure, les programmeurs ajoutaient un effet de brouillard. Les objets au-delà du plan limite étaient totalement dans le brouillard, puis ce brouillard se réduisait progressivement en se rapprochant de la caméra, avant de s'annuler à partir d'une certaine distance. Pour calculer le brouillard, on effectue un mélange ''alpha'' entre la couleur du pixel et une ''couleur de brouillard''. La différence est que l'on n'utilise pas la transparence pour faire le mélange, mais un '''coefficient de brouillard''', noté <math>\text{fog}(z)</math>. : <math>\text{Couleur finale} = \text{fog}(z) \times \text{Couleur de brouillard} + [ 1 - \text{fog}(z) ] \times \text{Couleur du pixel}</math> Le coefficient de brouillard dépend de la coordonnée de profondeur, de la distance du pixel par rapport à la caméra. Le brouillard démarre à une distance <math>z_{fog-start}</math>, et masque totalement les objets à partir d'une distance <math>z_{fog-end}</math>. Entre les deux, le coefficient de brouillard dépend de la distance. OpenGL autorise trois formules de calcul suivantes : : <math>\text{fog}(z) = \frac{z_{fog-end} - z}{z_{fog-end} - z_{fog-start}}</math> : <math>\text{fog}(z) = e^{- k \times z}</math> : <math>\text{fog}(z) = e^{- (k \times z)^2}</math> ==L'éclairage d'une scène 3D== L'éclairage d'une scène 3D calcule les ombres, mais aussi la luminosité de chaque pixel, ainsi que bien d'autres effets graphiques. Les algorithmes d'éclairage ont longtemps été implémentés directement en matériel, les cartes graphiques géraient l'éclairage dans des circuits spécialisés. Aussi, il est important de voir ces algorithmes d'éclairage. Il est possible d'implémenter l'éclairage à deux endroits différents du pipeline : juste avant la rastérisation, et après la rastérisation. ===Les sources de lumière et les couleurs associées=== L'éclairage d'une scène 3D provient de sources de lumières, comme des lampes, des torches, le soleil, etc. Il existe de nombreux types de sources de lumière, et nous n'allons parler que des principales. Elles sont au nombre de quatre et elles sont illustrées ci-dessous. [[File:3udUJ.gif|centre|vignette|upright=2|Types de sources de lumière.]] [[File:Graphics lightmodel directional.png|vignette|upright=1.0|Source de lumière directionnelle.]] Les '''sources directionnelles''' servent à modéliser des sources de lumière très éloignées, comme le soleil ou la lune. Elles sont simplement définies par un vecteur qui indique la direction de la lumière, rien de plus. Les '''sources ponctuelles''' sont des points, qui émettent de la lumière dans toutes les directions. Elles sont définies par une position, et une intensité lumineuse, éventuellement la couleur de la lumière émise. Il existe deyux types de sources de lumière ponctuelles. * Le premières émettent de manière égale dans toutes les directions. Elles sont appelées des ''point light'' dans le schéma du dessus. * Les secondes émettent de la lumière dans une '''direction privilégiée'''. L'exemple le plus parlant est celui d'une lampe-torche : elle émet de la lumière "tout droit", dans la direction où la lampe est orientée. Elles sont appelées des ''sport light'' dans le schéma du dessus. La direction privilégiée est un vecteur, notée v dans le schéma du dessous. [[File:Graphics lightmodel ambient.png|vignette|upright=1.0|Lumière ambiante.]] En théorie, la lumière rebondit sur les surfaces et a tendance à se disperser un peu partout à force de rebondir. C'est ce qui explique qu'on arrive à voir à l'intérieur d'une pièce si une fenêtre est ouverte. Il en résulte un certain '''éclairage ambiant''', qui est assez difficile à représenter dans un moteur de rendu 3D. Auparavant, l'éclairage ambiant était simulé par une lumière égale en tout point de la scène 3D, appelée simplement la '''lumière ambiante'''. Précisément, on suppose que la lumière ambiante en un point vient de toutes les directions et a une intensité constante, identique dans toutes les directions. Le tout est illustré ci-contre. C'est assez irréaliste, mais ça donne une bonne approximation de la lumière ambiante. ===La lumière incidente : le terme géométrique=== Pour simplifier, nous allons supposer que l'éclairage est calculé pour chaque sommet, pas par triangle. C'est de loin le cas le plus courant, aussi ce n'est pas une simplification abusive. La lumière qui arrive sur un sommet est appelée la '''lumière incidente'''. La couleur d'un sommet dépend de deux choses : la lumière incidente directe, comment il réfléchit cette lumière. Mathématiquement, il est possible de résumer cela avec le produit de deux termes : l'intensité de la lumière incidente, une fonction qui indique comment la surface réfléchit la lumière incidente. La fonction en question est appelée la '''réflectivité bidirectionnelle'''. Le terme anglais est ''bidirectional reflectance distribution function'', abrévié en BRDF, et nous utiliserons cette abréviation dans ce qui suit. : <math>\text{Couleur finale} = \text{Lumière incidente} \times BRDF(...)</math> La lumière incidente vient soit directement des sources de lumière, soit de la lumière qui a rebondit sur d'autres objets proches. La première est appelée la lumière directe, celle qui vient des rebonds s'appelle la lumière indirecte. Pour simplifier, la lumière indirecte est gérée par la lumière ambiante, nous passons sous silence les techniques d'illumination globale. En clair : nous allons nous limiter au cas où la lumière incidente vient directement d'une source de lumière, pas d'un rebond. Intuitivement, la lumière incidente est simplement égale à l'intensité de la source de lumière. Sauf que ce n'est qu'une approximation, et une assez mauvaise. En réalité, l'approximation est bonne si la lumière arrive proche de la verticale, mais elle est d'autant plus mauvaise que la lumière arrive penchée, voire rasante. La raison : la lumière incidente sera étalée sur une surface plus grande, si elle arrive penchée. Si vous vous souvenez de vos cours de collège, c'est le même principe qui explique les saisons. La lumière du soleil est proche de la verticale en été, mais est de plus en plus penché quand on s'avance vers l'Hiver. La lumière solaire est donc étalée sur une surface plus grande, ce qui fait qu'un point de la surface recevra moins de lumière, celle-ci étant diluée, étalée. [[File:Radiación solar.png|centre|vignette|upright=2|Exemple avec la lumière solaire.]] [[File:Angle of incidence.svg|vignette|upright=1|Angle d'incidence.]] En clair, tout dépend de l''''angle d'incidence''' de la lumière. Reste à voir comment calculer cet angle. La lumière incidente est définie par un vecteur, qui part de la source de lumière et atterrit sur le sommet considéré. Imaginez simplement que ce vecteur suit un rayon lumineux provenant de la source de lumière. Le vecteur pour la lumière incidente sera noté L. L'angle d'incidence est l'angle que fait ce vecteur avec la verticale de la surface, au niveau du sommet considéré. [[File:Graphics lightmodel ptsource.png|vignette|Normale de la surface.]] Pour cela, les calculs d'éclairage ont besoin de connaitre la verticale d'un sommet. Un sommet est donc associé à un vecteur, appelé la '''normale''', qui indique la verticale en ce point. Deux sommets différents peuvent avoir deux normales différentes, même s'ils sont proches. Elles sont d'autant plus différentes que la surface est rugueuse, non-lisse. La normale est prédéterminée lors de la création du modèle 3D, il n'y a pas besoin de le calculer. Par contre, elle est modifiée lors de l'étape de transformation, quand on place le modèle 3D dans la scène 3D. Les deux autres vecteurs sont à calculer à chaque image, car ils changent quand on bouge le sommet. La lumière qui arrive sur la surface dépend de l'angle entre la normale et le vecteur L. Précisément, elle dépend du cosinus de cet angle. En multipliant ce cosinus avec l'intensité de la lumière, on a la lumière arrivante. La couleur finale d'un pixel est donc : : <math>\text{Couleur finale} = I \times \cos{(N, L)} \times BRDF(...)</math> Le terme <math>I \times \cos{N, L}</math> ne dépend pas de la surface considérée. Juste de la position de la source de lumière, de la position du sommet et de son orientation par rapport à la lumière. Aussi, il est parfois appelé le '''terme géométrique''', en opposition aux propriétés de la surface. Les propriétés de la surface sont définies par un '''''material''''', qui indique comment il réfléchit la lumière, ainsi que sa texture. ===Le produit scalaire de deux vecteurs=== Calculer le terme géométrique demande de calculer le cosinus d'un angle. Et il n'est pas le seul : les autres calculs d'éclairage que nous allons voir demandent de calculer des cosinus. Or, les calculs trigonométriques sont très gourmands pour le GPU. Pour éviter le calcul d'un cosinus, les GPU utilisent une opération mathématique appelée le ''produit scalaire''. Le produit scalaire agit sur deux vecteurs, que l'on notera A et B. Un produit scalaire prend : la longueur des deux vecteurs, et l'angle entre les deux vecteurs noté <math>\omega</math>. Le produit scalaire est équivalent à la formule suivante : : <math>\text{Produit scalaire de deux vecteurs A et B} = \vec{A} \cdot \vec{B} = A \times B \times \cos{(\omega)}</math>, avec A et B la longueur des deux vecteurs A et B. L'avantage est que le produit scalaire se calcule simplement avec des additions, soustractions et multiplications, des opérations que les cartes graphiques savent faire très facilement. Le produit scalaire de deux vecteurs de coordonnées x,y,z est le suivant : : <math>\vec{A} \cdot \vec{B} = x_A \times x_B + y_A \times y_B + z_A \times z_B</math> En clair, on multiplie les coordonnées identiques, et on additionne les résultats. Rien de compliqué. Un avantage est que tous les vecteurs vus précédemment sont normalisés, à savoir qu'ils ont une longueur qui vaut 1. Ainsi, le calcul du produit scalaire devient équivalent au calcul du produit scalaire. ===La réflexion de la lumière sur la surface=== [[File:Ray Diagram 2.svg|vignette|Reflection de la lumière sur une surface parfaitement lisse.]] Maintenant que nous venons de voir le terme géométrique, voyons le BRDF, qui définit comment la surface de l'objet 3D réfléchit la lumière. Vos cours de collège vous ont sans doute appris que la lumière est réfléchie avec le même angle d'arrivée. L'angle d'incidence et l'angle de réflexion sont égaux, comme illustré ci-contre. On parle alors de '''réflexion parfaite'''. Mais cela ne vaut que pour une surface parfaitement lisse, comme un miroir parfait. Dans la réalité, une surface a tendance à renvoyer des rayons dans toutes les directions. La raison est qu'une surface réelle est rugueuse, avec de petites aspérités et des micro-reliefs, qui renvoient la lumière dans des directions "aléatoires". La lumière « rebondit » sur la surface de l'objet et une partie s'éparpille dans un peu toutes les directions. On parle alors de '''réflexion diffuse'''. {| |- |[[File:Dioptre reflexion diffuse speculaire refraction.svg|vignette|upright=1.4|Différence entre réflexion diffuse et spéculaire.]] |[[File:Diffuse reflection.svg|vignette|upright=1|Réflexion diffuse.]] |} Maintenant, imaginons que la surface n'ait qu'une réflexion diffuse, pas d'autres formes de réflexion. Et imaginons aussi que cette réflexion diffuse soit parfaite, à savoir que la lumière réfléchie soit renvoyée à l'identique dans toutes les directions, sans aucune direction privilégiée. On a alors le ''material'' le plus simple qui soit, appelé un '''''diffuse material'''''. Vu que la lumière est réfléchie à l'identique dans toutes les directions, elle sera identique peu importe où on place la caméra. La lumière finale ne dépend donc que des propriété de la surface, que de sa couleur. En clair, il suffit de donner une '''couleur diffuse''' à chaque sommet. La couleur diffuse est simplement multipliée par le terme géométrique, pour obtenir la lumière réfléchie finale. Rien de plus, rien de moins. Cela donne l'équation suivante, avec les termes suivants : * L est le vecteur pour la lumière incidente ; * N est la normale du sommet ; * I est l'intensité de la source de lumière ; * <math>C_d</math> est la couleur diffuse. : <math>\text{Illumination diffuse} = C_d \times \left[ I \times (\vec{N} \cdot \vec{L}) \right]</math> Rajoutons maintenant l'effet de la lumière ambiante à un ''material'' de ce genre. Pour rappel, la lumière ambiante vient de toutes les directions à part égale, ce qui fait que son angle d'incidence n'a donc pas d'effet. L'intensité de la lumière ambiante est déterminée lors de la création de la scène 3D, c'est une constante qui n'a pas à être calculée. Pour obtenir l'effet de la lumière ambiante sur un objet, il suffit de multiplier sa couleur diffuse par l'intensité de la lumière ambiante. Cependant, de nombreux moteurs de jeux ajoutent une '''couleur ambiante''', différente de la couleur diffuse. : <math>\text{Illumination ambiante} = C_a \times I_a</math> avec <math>C_a</math> la couleur ambiante du point de surface et <math>I_a</math> l'intensité de la lumière ambiante. En plus de la réflexion diffuse parfaite, de nombreux matériaux ajoutent une '''réflexion spéculaire''', qui n'est pas exactement la réflexion parfaite, en est très proche. Les rayons réfléchis sont très proches de la direction de réflexion parfaite, et s'atténuent très vite en s'en éloignant. Le résultat ressemble à une sorte de petit "point blanc", très lumineux, orienté vers la source de lumière, appelé le '''''specular highlight'''''. La réflexion diffuse est prédominante pour les matériaux rugueux, alors que la réflexion spéculaire est dominante sur les matériaux métalliques ou très lisses. [[File:Phong components version 4.png|centre|vignette|upright=3.0|Couleurs utilisées dans l'algorithme de Phong.]] [[File:Phong Vectors.svg|vignette|Vecteurs utilisés dans l'algorithme de Phong (et dans le calcul de l'éclairage, de manière générale).]] Pour calculer la réflexion spéculaire, il faut d'abord connaitre le vecteur pour la réflexion parfaite, que nous noterons R dans ce qui suit. Le vecteur R peut se calculer avec la formule ci-dessous : : <math>\vec{R} = 2 (\vec{L} \cdot \vec{N}) \times \vec{N} - \vec{L} </math> La réflexion spéculaire dépend de l'angle entre la direction du regard et la normale : plus celui-ci est proche de l'angle de réflexion parfaite, plus la réflexion spéculaire sera intense. Le vecteur pour la direction du regard sera noté V, pour vue ou vision. La réflexion spéculaire est une fonction qui dépend de l'angle entre les vecteurs R et V. Le calcul de la réflexion spéculaire utilise une '''couleur spéculaire''', qui est l'équivalent de la couleur diffuse pour la réflexion spéculaire. : <math>\text{BRDF spéculaire} = C_s \times f(\vec{R} \cdot \vec{V}) </math> La fonction varie grandement d'un modèle de calcul spéculaire à l'autre. Aussi, je ne rentre pas dans le détail. L'essentiel est que vous compreniez que le calcul de l'éclairage utilise de nombreux calculs géométriques, réalisés avec des produits scalaires. Les calculs géométriques utilisent la couleur d'un sommet, la normale du sommet, et le vecteur de la lumière incidente. Les autres informations sont calculées à l'exécution. ===Les algorithmes d'éclairage basiques : par triangle, par sommet et par pixel=== Dans tout ce qui a été dit précédemment, l'éclairage est calculé pour chaque sommet, ce qui fait qu'on parle d''''éclairage par sommet''', ou ''vertex lighting''. Il attribue une illumination/couleur à chaque sommet de la scène 3D. Mais une fois qu'on a obtenu la couleur des sommets, reste à colorier les triangles. Pour obtenir la couleur finale d'un pixel, l'éclairage par sommet fait une moyenne de la couleur de chaque sommet. La moyenne est une moyenne pondérée, qui tient compte de la distance du sommet avec le pixel. Plus le pixel est loin d'un sommet, plus son coefficient est petit. Typiquement, le coefficient varie entre 0 et 1 : de 1 si le pixel est sur le sommet, à 0 si le pixel est sur un des sommets adjacents. La moyenne effectuée est généralement une interpolation bilinéaire, qui est prise en charge par l'étape de rastérisation. L'éclairage par sommet a eu son heure de gloire, mais il est maintenant remplacé par l''''éclairage par pixel''' (''per-pixel lighting''), qui calcule l'éclairage pixel par pixel. En clair, l’éclairage est finalisé après l'étape de rastérisation, il ne se fait pas qu'au niveau de la géométrie. Il existe plusieurs types d'éclairage par pixel, mais on peut les classer en deux grands types : l'éclairage de Phong et le ''bump/normal mapping''. L''''éclairage de Phong''' calcule l'éclairage pixel par pixel. Avec cet algorithme, la géométrie n'est pas éclairée : les couleurs des sommets ne sont pas calculées. A la place, les normales sont envoyées à l'étape de rastérisation, qui effectue une opération d'interpolation, qui renvoie une normale pour chaque pixel. Les calculs d'éclairage utilisent alors ces normales pour faire les calculs d'éclairage pour chaque pixel. {| |- |[[File:Per vertex lighting.png|vignette|upright=1|Eclairage par sommet.]] |[[File:Per fragment lighting.png|vignette|upright=1|Eclairage de Phong.]] |- |[[File:Per vertex lighting example.png|vignette|upright=1|Eclairage par sommet.]] |[[File:Per fragment lighting example.png|vignette|upright=1|Eclairage de Phong.]] |} La technique du '''''normal mapping''''' est assez simple à expliquer, sans compter que plusieurs cartes graphiques l'ont implémentée directement dans leurs circuits. Là où l'éclairage de Phong interpole les normales pour chaque pixel, le ''normal-mapping'' précalcule les normales d'une surface dans une texture, appelée la ''normal-map''. Lors du rendu 3D, la carte graphique lit les normales adéquates directement depuis cette texture, puis fait les calculs d'éclairage avec. Le ''normal mapping'' permettent d'ajouter du relief et des détails sur des surfaces planes en jouant sur l'éclairage. Il permet de simplifier grandement la géométrie rendue, tout en utilisant l'éclairage pour compenser. [[File:WallSimpleAndNormalMapping.png|centre|vignette|upright=2|Différence sans et avec ''normal-mapping''.]] ===Les ''shaders'' : des programmes exécutés sur le GPU=== Maintenant que nous venons de voir les algorithmes d'éclairages, il est temps de voir comment les réaliser sur une carte graphique. Nous venons de voir qu'il y a une différence entre l'éclairage par pixel et par sommet. Intuitivement, l'éclairage par sommet devrait se faire avec les calculs géométriques, alors que l'éclairage par pixel devrait se faire après avoir appliqué les textures. Les toutes premières cartes graphiques ne géraient ni l'éclairage par sommet, ni l'éclairage par pixel. Elles laissaient les calculs géométriques au CPU. Par la suite, la Geforce 256 a intégré '''circuit de ''Transform & Lightning''''', qui s'occupait de tous les calculs géométriques, éclairage par sommet inclus (d'où le L de T&L). Elle gérait alors l'éclairage par sommet, mais un algorithme particulier, qui n'était pas très flexible. Il ne gérait que des ''material'' bien précis (des ''Phong materials''), rien de plus. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | Unité de T&L : géométrie | Rastérisation | Placage de textures | ''Raster Operations Pipeline'' |} L'amélioration suivante est venue sur la Geforce 3 : l'unité de T&L est devenue programmable. Au vu le grand nombre d'algorithmes d'éclairages possibles et le grand nombre de ''materials'' possibles, c'était la seule voie possibles. Les programmeurs pouvaient programmer leurs propres algorithmes d'éclairage par sommet, même s'ils devaient aussi programmer les étapes de transformation et de projection. Mais nous détaillerons cela dans un chapitre dédié sur l'historique des GPUs. Ce qui est important est que la Geforce 3 a introduit une fonctionnalité absolument cruciale pour le rendu 3D moderne : les '''''shaders'''''. Il s'agit de programmes informatiques exécutés par la carte graphique, qui servaient initialement à coder des algorithmes d'éclairage. D'où leur nom : ''shader'' pour ''shading'' (éclairage en anglais). Cependant, l'usage modernes des shaders dépasse le cadre des algorithmes d'éclairage. L'avantage est que cela simplifie grandement l'implémentation des algorithmes d'éclairage. Pas besoin de les intégrer dans la carte graphique pour les utiliser, pas besoin d'un circuit distinct pour chaque algorithme. Sans shaders, si la carte graphique ne gère pas un algorithme d'éclairage, on ne peut pas l'utiliser. A la rigueur, il est parfois possible de l'émuler avec des contournements logiciels, mais au prix de performances souvent désastreuses. Avec des shaders, il est possible de programmer l'algorithme d'éclairage de notre choix, pour l'exécuter sur la carte graphique, avec des performances plus que convenables. [[File:Implémentation de l'éclairage sur les cartes graphiques.png|vignette|Implémentation de l'éclairage sur les cartes graphiques]] Il existe plusieurs types de shaders, mais les deux principaux sont les '''''vertex shaders''''' et les '''''pixel shaders'''''. Les pixels shaders s'occupent de l'éclairage par pixel, leur nom est assez parlent. Les vertex shaders s'occupent de l'éclairage par sommet, mais aussi des étapes de transformation/projection. Je parle bien des trois étapes de transformation vues plus haut, qui effectuent des calculs de transformation de coordonnées avec des matrices. La raison à cela est que les calculs de transformation ressemblent beaucoup aux calculs d'éclairage par sommet. Ils impliquent tous deux des calculs vectoriels, comme des produits scalaires et des produits vectoriels, qui agissent sur des sommets/triangles. Si la carte graphique incorpore un processeur de shader capable de faire de tels calculs, alors il peut servir pour les deux. Pour implémenter les shaders, il a fallu ajouter des processeurs à la carte graphique. Les processeurs en question exécutent les shaders, ils peuvent lire ou écrire dans des textures, mais ne font rien d'autres. Les ''vertex shaders'' font tout ce qui a trait à la géométrie, ils remplacent l'unité de T&L. Les pixels shaders sont entre la rastérisation et les ROPs, ils sont très liés à l'unité de texture. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | rowspan="2" class="f_rouge" | ''Vertex shader'' | rowspan="2" | Rastérisation | Placage de textures | rowspan="2" |''Raster Operations Pipeline'' |- | class="f_rouge" | ''Pixel shader'' |} <noinclude> {{NavChapitre | book=Les cartes graphiques | prev=Les cartes d'affichage des anciens PC | prevText=Les cartes d'affichage des anciens PC | next=Avant les GPUs : les cartes accélératrices 3D | nextText=Avant les GPUs : les cartes accélératrices 3D }}{{autocat}} </noinclude> 1tv4yso4s4191t7rlsrzq8mwhojcef2 Les cartes graphiques/Le pipeline géométrique d'un GPU 0 79241 765157 764874 2026-04-26T20:19:18Z Mewtow 31375 /* La conservation de l'ordre des sommets entrants et sortants */ 765157 wikitext text/x-wiki Dans le chapitre précédent, nous avons vu qu'il y a une différence entre le pipeline géométrique des anciennes stations de travail et des ordinateurs personnels. Les premiers tendaient à utiliser des processeurs flottants, programmés avec un ''firmware/microcode'' non-modifiable. Les ordinateurs personnels ont eu commencé avec des circuits géométriques fixe, pour les rendre de plus en plus programmables. Dans ce chapitre, nous allons étudier les circuits géométriques d'un GPU d'ordinateur personnel, et voir comment ils ont évolués dans le temps. ==Le ''vertex pipeline''== Les premières cartes graphiques ne traitaient que des sommets, les primitives n'apparaissaient qu'à l'étape de rastérisation. Leur pipeline a progressivement évolué pour pouvoir exécuter des ''shaders'' sur des primitives, mais ce n'est apparu qu'avec DirectX 10. Avant, les unités géométriques ne géraient que des sommets. Nous allons voir de telles unités géométriques ici. Elles sont composées de trois circuits : l'''input assembly'', l'unité géométrique proprement dit, et l'assemblage des primitives. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | rowspan="2" class="f_rouge" | ''Input assembly'' | ''Transform & Lighting'' | rowspan="2" class="f_rouge" | ''Primitive assembly'' |- | ''Vertex shader'' |} Pour comprendre à quoi servent l'''input assembler'' et l'assemblage de primitives, il faut parler de certaines optimisations présentes sur les cartes graphiques de l'époque. ===Les représentations des maillages : les optimisations=== Les optimisations visaient à réduire la mémoire prise pour les objets 3D. Pour rappel, les objets géométriques et la scène 3D sont mémorisés dans la mémoire vidéo, avec un assemblage de triangles collés les uns aux autres, l'ensemble formant un '''maillage'''. Pour mémoriser un maillage, il suffit d'utiliser une liste de triangles, chaque triangle étant définit par trois sommets consécutifs. Cependant, utiliser cette représentation gaspille beaucoup de mémoire ! [[File:Représentation naive d'un maillage 3D.png|centre|vignette|upright=2|Représentation naive d'un maillage 3D]] [[File:Cube colored.png|vignette|Cube en 3D]] Pour comprendre pourquoi, il faut savoir qu'un sommet est très souvent partagé par plusieurs triangles. Pour comprendre pourquoi, prenons l'exemple du cube de l'image ci-contre. Le sommet rouge du cube appartient aux 3 faces grise, jaune et bleue, et sera présent en trois exemplaires dans le tampon de sommets : un pour la face bleue, un pour la jaune, et un pour la grise. Et si vous croyez que l'exemple du cube n'est pas réaliste, voici un chiffre obtenu empiriquement, par analyse de maillages utilisés dans un JV : en moyenne, un sommet est dupliqué en 6 exemplaires. Pour éviter ce gâchis, les concepteurs d'API et de cartes graphiques ont inventé des représentations pour les maillages, qui visent éliminer cette redondance. Nous les appellerons des '''représentations compressées''', bien que ce terme soit un peu trompeur. Mais dans les faits, il s'agit bien d'une forme de compression de données, bien que très différente de celle utilisée pour compresser un fichier, de la vidéo, du texte ou de l'audio. La liste de triangle est en quelque sorte compressée lors de la création du maillage, puis décompressée par le matériel. Les représentations compressées n'utilisent pas une liste de triangles, mais une liste de sommets. La liste de sommets est mémorisée en mémoire vidéo, et s'appelle le '''tampon de sommets'''. Ainsi, un sommet présent dans plusieurs triangles n'est mémorisé qu'une seule fois, ou presque. Reste à reconstituer les triangles à partir de cette liste de sommets. Et c'est le travail de l'''input assembler'' et l'assemblage de primitive, justement. Mais avant de comprendre ce qu'ils font, nous devons voir les représentations compressées utilisées sur les cartes graphiques de l'époque. Les premières versions d'Open GL et Direct X implémentaient deux représentations compressées : les ''triangle fans'' et celle des ''triangle strips''. Elles ont été remplacées par la représentation indicée, apparue avec Direct X 7 et les versions équivalentes d'Open GL. Nous allons voir cette dernière en premier, car elle est plus simple. La '''représentation indicée''' stocke les triangles et les sommets séparément, avec une liste de triangle séparée de la liste de sommets. Dit comme cela, on ne voit pas vraiment où se trouve le gain en mémoire. Mais il y a une astuce, qui tient à ce qu'on met dans la liste de triangles. Les sommets sont numérotés, le numéro indiquant leur place dans la liste de sommets. Dans la liste de triangles, un triangle est mémorisé non pas par trois sommets consécutifs, mais par trois numéros de sommets. Le numéro est aussi appelé l'indice du sommet, et la liste de triangles est appelée le ''tampon d'indices''. : Le terme '"indice" devrait rappeler quelques chose à ceux qui savent ce qu'est un tableau en programmation. Le résultat est que les sommets ne sont pas dupliqués, mais on doit ajouter un tampon d'indice pour compenser. L'astuce est que l'économie en termes de sommets dépasse largement l'ajout du tampon d'indice. En effet, un indice prend moins de place qu'un sommet. Un sommet demande trois coordonnées, une couleur de sommet, des coordonnées de texture, une normale et bien d'autres attributs de sommets. En comparaison, un indice est un simple numéro, un nombre entier. En moyenne, un sommet prend 10 fois plus de place qu'un indice. Si on fait le compte, au lieu d'avoir N copies d'un sommet, on a juste une seule copie et N indices. L'économie liée à la taille des indices l'emporte. : On pourrait remplacer les indices par des pointeurs, ce qui donnerait un cas particulier d'une structure de données connue sous le nom de vecteur de Liffe. Mais ce n'est pas très pratique et n'est pas utilisé dans le domaine du rendu 3D. Un numéro entier est plus court qu'un pointeur complet. [[File:Représentation indicée d'un maillage 3D.png|centre|vignette|upright=2|Représentation indicée d'un maillage 3D]] Les premières versions d'Open GL et Direct X implémentaient deux représentations compressées : les ''triangle fans'' et celle des ''triangle strips''. Elles sont plus complexes, mais permettent une économie de mémoire encore plus importante. La technique des '''triangles fan''' était la moins utilisée des deux, mais elle est plus simple à expliquer, ce qui fait que je commence avec elle. Elle permet de dessiner des triangles qui partagent un sommet unique, ce qui donne une forme soit circulaire, soit en forme d'éventail. Les ''triangles fans'' sont utiles pour créer des figures comme des cercles, des halos de lumière, etc. Un triangle est définit par le sommet partagé, puis deux sommets. Le sommet partagé n'est présent qu'en un seul exemplaire, et une autre optimisation permet d'optimiser les deux autres sommets. [[File:Triangle fan.png|centre|vignette|upright=2.0|Triangle fan]] Avec cette représentation, le tampon de sommets contient une liste de sommets, qui est interprétée sommet par sommet. Le premier sommet est le sommet partagé par tous les triangles du ''triangle fan''. Le premier triangle est définit par le sommet partagé et deux nouveaux sommets. Les triangles suivants sont eux définit par un seul sommet, pas deux. En effet, deux triangles consécutifs partagent une arête, définie par le sommet partagé et un des deux sommets. Sur les deux sommets, le dernier sommet est celui de l'arête partagée. En faisant ainsi, un triangle est définit par un nouveau sommet, le sommet précédent dans le tampon de sommet, et le sommet partagé. {|class="wikitable" |- ! Tampon de sommet !! Triangle 1 !! Triangle 2 !! Triangle 3 !! Triangle 4 !! Triangle 5 !! Triangle 6 !! Triangle 7 !! ... |- | Sommet 1 || X || X || X || X || X || X || X || X |- | Sommet 2 || X || || || || || || |- | Sommet 3 || X || X || || || || || |- | Sommet 4 || || X || X || || || || |- | Sommet 5 || || || X || X || || || |- | Sommet 6 || || || || X || X || || |- | Sommet 7 || || || || || X || X || |- | Sommet 8 || || || || || || X || X |} La technique des '''triangles strip''' optimise le rendu de triangles placés en série, comme illustré dans le schéma ci-dessous. Notez que deux consécutifs ont deux sommets en commun. L'idée est alors que quand on passe au triangle suivant, on ne précise que le sommet restant, pas les deux sommets en commun. [[File:Triangle strip.svg|centre|vignette|upright=2|Triangle strip]] L'implémentation est assez simple : dans le tampon de sommets, trois sommets consécutifs forment un triangle. Et pour passer d'un triangle au suivant, on ne saute pas de trois sommets, on passe d'un sommet au suivant. {|class="wikitable" |- ! Tampon de sommet !! Triangle 1 !! Triangle 2 !! Triangle 3 !! Triangle 4 !! Triangle 5 !! Triangle 6 !! ... |- | Sommet 1 || X || || || || || |- | Sommet 2 || X || X || || || || |- | Sommet 3 || X || X || X || || || |- | Sommet 4 || || X || X || X || || |- | Sommet 5 || || || X || X || X || |- | Sommet 6 || || || || X || X || X |- | Sommet 7 || || || || || X || X |- | Sommet 8 || || || || || || X |} Les ''triangle fan'' et ''triangle strip'' permettent une économie de mémoire conséquente, comparé à la représentation non-compressée. Au lieu de trois sommets pour chaque triangle, on se retrouve avec un sommet pour chaque triangle, plus les deux premiers sommets. La comparaison avec l'usage d'un tampon d'indice dépend de la taille des indices, mais ''triangle fan'' et ''triangle strip'' sont plus économes niveau mémoire vidéo. Un problème est que les ''triangle strip'' ne permettent pas de représenter tous les modèles 3D, certains ne sont simplement pas compatibles avec cette représentation. Et pour les ''triangle fan'', c'est encore pire ! Cependant, il est souvent possible de ruser, ce qui permet de faire rentrer des modèles non-coopératifs dans un ''triangle strip'', mais quelques sommets sont alors redondants. ===L'''input assembler'' et le tampon d'indice=== Les représentations précédentes ont une influence importante sur le pipeline géométrique. Pour les gérer, il a fallu non seulement modifier l'assemblage de primitives, mais aussi rajouter un circuit juste avant l'unité géométrique : l'''input assembler''. Il charge les sommets depuis la mémoire vidéo, pour les injecter dans le reste du pipeline. [[File:Input assembler.png|centre|vignette|upright=2.0|Input assembler]] Pour faire son travail, il a besoin de l'adresse des données géométriques en mémoire, leur taille et éventuellement du type des données qu'on lui envoie (sommets codées sur 32 bits, 64, 128, etc). En clair, il doit connaitre l'adresse du tampon de sommet et éventuellement celle du tampon d'indice. Et en général, c'est une unité d'accès mémoire un peu particulière, qui contient des circuits assez classiques pour ce genre de circuits : des circuits de calcul d'adresse, des circuits pour commander la mémoire VRAM, un contrôleur mémoire, diverses mémoires tampons, etc. Il procède différemment suivant la représentation utilisée. Il peut lire trois sommets consécutifs avec une représentation non-compressée, il peut lire un tampon d'indice et l'utiliser pour charger les sommets adéquats, il peut lire un sommet à la fois avec les ''triangle fan/strip'', etc. Tout dépend de comment l'unité est configurée. Dans ce qui suit, nous allons étudier un ''input assembler'' qui gère la représentation indicée. Il peut être adapté pour gérer les autres représentations assez simplement. L'idée est que l'''input assembler'' est composé de trois circuits principaux : un qui lit le tampon d'indice, un autre qui lit le tampon de sommets, un dernier qui package les sommets. Le premier lit les indices depuis la mémoire vidéo. Le second récupère l'indice chargé par le premier, et lit le sommet associé dans le tampon de sommets. Ils sont respectivement appelés avec les noms : ''index fetch'' et ''vertex fetch''. Le dernier circuit se contente de formater les sommets pour qu'ils soient compréhensibles par les unités géométriques. [[File:Implémentation matérielle de l'input assembler.png|centre|vignette|upright=2|Implémentation matérielle de l'input assembler.]] Pour les représentations autres qu'indicée, seul le ''vertex fetch'' est utilisé. Il se contente alors de balayer le tampon de sommets dans l'ordre, du premier sommet au dernier. Un vulgaire compteur d'adresse suffit pour cela. Avec la représentation indicée, le circuit d'''index fetch'' est utilisé. Il balaye un tableau d'indices du début à la fin, ce qui fait que le calcul d'adresse est réalisé par un simple compteur d'adresse. Le circuit de ''vertex fetch'' fait des calculs d'adresse un chouilla moins simples, mais qui se contentent de combiner l'adresse du tampon de sommets avec l'indice. Les unités de ''index fetch'' et de ''vertex fetch'' font donc des calculs d'adresse et des accès mémoire. Par contre, les deux circuits peuvent implémenter des mémoires caches, pour améliorer les performances. Vous remarquerez que l’''input assembler'' fait surtout des calculs d'adresse, des lectures en mémoire, et des conversions de format de données. Un processeur de ''vertex shader'' peut faire la même chose, ce qui fait qu'il est possible d'émuler l'''input assembler'' avec un ''vertex shader''. La seule condition, absolument nécessaire, est que le ''vertex shader'' puisse lire des données en mémoire vidéo. Et pas seulement lire des textures, comme le permettent les techniques de ''vertex texturing'', mais de vraies lectures arbitraires, pour lire les tampons de sommet/indice. Cette possibilité est arrivée avec Direct X 10, ce qui fait que l’''input assembler'' peut être émulé par les ''vertex shaders'' à partir de cette version de Direct X. De nos jours, tous les GPUs font à leur sauce. Certains émulent l’''input assembler'' avec des ''shaders'', d'autres non. Ceux qui le font le font en modifiant les ''vertex shaders''. Le ''driver'' du GPU injecte du code dans les ''vertex shaders'', code qui émule l'''input assembler''. ===Les caches de sommets : une optimisation du tampon d'indice=== Idéalement, le ''vertex shader'' doit être exécuté une seule fois par sommet (idem pour son équivalent avec une unité de T&L). Mais quand des sommets sont dupliqués, ce n'est pas le cas. Le problème se comprend bien si on prend une représentation non-compressée, où les sommets sont dupliqués si nécessaires. Le résultat est que les copies d'un même sommet sont toutes lues depuis la mémoire, transformées, éclairées, puis envoyées à l'unité d'assemblage de primitives. En clair : un sommet est lu en VRAM plusieurs fois, et subit des calculs géométriques redondants. Ce qui est un problème. Les représentations compressées permettent de grandement réduire cette redondance. Les ''triangle strip'' et ''triangle fan'' sont de loin les plus efficaces, de ce point de vue : un sommet n'est chargé qu'une seule fois, et n'est traité qu'une seule fois. Du moins, si tout se passe bien. En effet, pour convertir un modèle 3D en ''triangle strip/fan'', il faut parfois ruser, ce qui fait que des sommets sont redondants. Avec la représentation indicée, l'''input assembler'' doit détecter quand un sommet dupliqué a déjà été rencontré. Si un tel sommet dupliqué est détecté, on récupère le sommet déjà calculé, plutôt que de refaire les calculs. Mais cela demande d'ajouter une mémoire cache pour mémoriser les sommets transformés/éclairés. Elle est appelée le '''''Post Transform Cache''''' et il est crucial pour éviter les calculs redondants. L'idée est la suivante : en sortie de l’''index fetch'', un circuit regarde les indices chargés et vérifie s'ils ont déjà été rencontrés. Si l'indice est inconnu, alors on suppose que le sommet associé n'a jamais été rencontré. L'indice est envoyé à l'unité de ''vertex fetch'', le sommet est chargé depuis le tampon de sommet et envoyé à l'unité géométrique. Par contre, si l'indice est reconnu, c'est que le sommet associé a déjà été transformé/éclairé : on lit alors le sommet transformé depuis le ''Post Transform Cache''. Pour détecter un sommet déjà rencontré, rien de plus simple : il suffit de consulter le ''Post Transform Cache''. Une fois un indice chargé, le ''Post Transform Cache'' est consulté pour vérifier s'il a une copie du sommet associé. Le cache répond alors soit en disant qu'il n'a pas le sommet associé, soit il renvoie le sommet transformé. Le ''Post Transform Cache'' est consulté en lui envoyant l'indice du sommet, et potentiellement de quoi identifier le tampon d'indice utilisé. C'est pour ne pas confondre deux sommets appartenant à deux modèles différents mais qui ont le même indice par hasard. Deux solutions pour cela : soit on utilise un identifiant pour le tampon d'indice utilisé (pas une adresse), soit on vide le cache entre deux ''draw call''. Il est vraisemblable que tout soit plus compliqué. En, effet, il faut tenir compte du cas où un sommet est en cours de calcul. Pour gérer ce cas, il est probable que l’''input assembler'' réserve de la place dans ce cache à l'avance. Quand un sommet est envoyé aux unités géométriques, l’''input assembler'' doit réserver de la place dans le cache, en mettant l'indice dans le ''tag'' du cache, et en laissant la ligne de cache vide. Le ''Post Transform Cache'' mémorise les N derniers sommets rencontrés. Elle est souvent qualifiée de mémoire FIFO, mais c'est un intermédiaire entre une mémoire cache du point de vue des lectures, et une mémoire FIFO du point de vue des écritures. Il mémorise entre 16 et 64 sommets, pas plus. Aller au-delà ne sert pas à grand chose, vu que des sommets dupliqués sont très souvent proches en mémoire RAM et sont traités dans une fenêtre temporelle assez petite. [[File:Post-transform cache.png|centre|vignette|upright=2|Post-transform cache]] Le ''Post-transform cache'' se trouve donc en sortie de l'unité d’''index fetch''. Mais serait-il possible d'ajouter un second cache, cette fois-ci pour l'unité de ''vertex fetch'' ? Un tel cache existe lui aussi, et s’appelle le '''''pre-transform cache'''''. Il mémorise les sommets chargés, mais pas encore transformés/éclairés. Il se situe entre l'unité de ''vertex fetch'' et l'unité géométrique. Intuitivement, on se dit qu'il évite de charger un sommet plusieurs fois. Mais ce n'est en réalité qu'un intérêt secondaire, bon à prendre, mais pas primordial. En réalité, il permet de profiter du fait que le ''vertex fetch'' charge les sommets par paquets de 32 à 64 sommets, qui sont copiés dans le cache de sommets. Ainsi, quand on charge un sommet, les 32/64 suivants sont chargés avec et sont disponibles pour l'unité de ''vertex shader'' si celle-ci en a besoin dans le futur, ce qui a de très fortes chances d'être le cas. De plus, il est possible de précharger des lignes de cache : quand le ''vertex fetch'' lit un paquet de sommets, le paquet de sommet est copié dans le cache, mais les paquets suivants peuvent aussi être chargés en avance. Une telle technique de '''préchargement'' permet d'améliorer les performances. [[File:Pre- et Post-transform cache.png|centre|vignette|upright=2|Pre- et Post-transform cache]] Pour résumer, l’''input assembler'' contient deux caches, qui sont collectivement appelés des '''caches de sommets'''. Le ''Post Transform Cache'' a disparu dans certains GPU modernes. Je recommande la lecture de l'article "Revisiting The Vertex Cache : Understanding and Optimizing Vertex Processing on the modern GPU" à ce sujet. Quant au ''Pre Transform Cache'', il a été remplacé par des mémoires caches généralistes, qui ne sont pas spécialisées dans les sommets. ===L'assemblage de primitives=== En sortie des unités géométriques, on a des sommets éclairés et colorisés, pas des triangles. Pour recréer des triangles, on doit lire les sommets dans l'ordre adéquat, par paquets de trois pour obtenir des triangles. C'est le rôle de l''''étape d'assemblage de primitives''' (''primitive assembly''), qui regroupe les sommets appartenant au même triangle, à la même primitive. L'assemblage des primitives est réalisée par un circuit fixe, non-programmable, qui utilise le tampon d'indice pour regrouper les sommets en primitives. Un problème pour l'assemblage de primitives est que les sommets n’arrivent pas dans l'ordre. Il arrive que des sommets soit traités plus vite que les autres, et passent devant. Le pipeline ne peut pas se baser sur l'ordre d'arrivée des sommets, pour regrouper les sommets en triangles. Pour gérer ces temps de calcul variable, le pipeline mémorise les triangles en sortie des unités géométriques et attend que tous les sommets d'un triangles soient disponibles. La méthode pour cela dépend de la représentation utilisée. L'assemblage des primitives ne se passe pas pareil avec les ''triangle strip'', ''triangle fan'', représentation indicée et représentation non-compressées. Avec la représentation non-compressée, l'assemblage de primitives regroupe les triangles par paquets de trois, rien de plus. Mais attention, des triangles consécutifs en mémoire ne sortent pas des unités géométriques l'un à la suite de l'autre. Pour gérer ça, l'''input assembler'' associe, un numéro à chaque triangle, qui indique sa place dans le tampon de sommets, qui est un indice. L'assemblage de primitive regarde ces numéros pour regrouper les triangles. Il attend que trois numéros consécutifs soient disponibles pour assembler le prochain triangle. Pour l'adressage indicé, il procède comme la représentation non-compréssée, sauf qu'il regarde le tampon d'indice. Il lit le tampon d'indice en partant du début, et fait des groupes de trois indices consécutifs. Les sommets sont associés avec leur indice, qui les accompagne lors de leur trajet dans le pipeline géométrique. Une fois qu'ils sortent des unités géométriques, ils sont accumulés dans une mémoire juste avant l'unité de primitive, et l'assemblage de primitive attend que les trois sommets avec les trois indices adéquats soient disponibles. Avec les ''triangle strip'', il mémorise les deux derniers sommets chargés, pour les combiner avec le prochain sommet à charger. L'implémentation matérielle est assez simple : un registre pour mémoriser le premier sommet, une mémoire FIFO pour mémoriser les deux sommets les plus récents. Pour générer un triangle, l'étape d'assemblage de primitive lit le registre et la mémoire FIFO, pour récupérer les trois sommets. Avec les ''triangle fan'', il doit mémoriser le sommet partagé, et le dernier sommet chargé, ce qui demande deux registres. ==Les ''geometry shaders''== Les GPU d'avant DirectX 10, qui n'avaient que les ''vertex shaders'' et ne pouvaient manipuler que des sommets. Depuis DirectX 10, le pipeline graphique a intégré des techniques pour gérer nativement des triangles dans les ''shaders''. Dans ce chapitre, nous allons étudier le pipeline graphique de DirectX 10, DirectX 11 et DirectX 12. L'intérêt est que cela permet de faciliter l'implémentation de techniques de tesselation, sans compter que certaines optimisations deviennent plus simples à effectuer. Dans ce chapitre, nous allons étudier le pipeline graphique de DirectX 10, DirectX 11 et DirectX 12. DirectX 10 et OpenGl 3.2 ont introduit les ''geometry shaders'', juste avant l'étape d'assemblage des primitives. Les ''geometry shaders'' peuvent ajouter, supprimer ou altérer des primitives dans une scène 3D. Un ''geometry shader'' prend en entrée un point, une ligne ou un triangle, donc les trois primitives de base supportées sur les GPU modernes. Il émet en sortie : soit un ''triangle strip'', soit une ''line strip'' (c'est à une ligne ce qu'un d'un ''triangle strip'' est à un triangle) ou un point. Ils n'ont pas été très utilisés, leurs utilisations étant assez limitées. Ils peuvent en théorie être utilisés pour la gestion des ''cubemaps'', le ''shadow volume extrusion'', la génération de particules, et quelques autres effets graphiques. Ils pourraient aussi être utilisés pour faire de la tesselation, mais leurs limitations font que ce n'est pas pratique. Rappelons que les ''geometry shaders'' sont optionnels et que beaucoup de jeux vidéos ou de moteurs de rendu 3D n'en utilisent pas. ===La conservation de l'ordre des sommets entrants et sortants=== Les ''geometry shaders'' sont exécutés après l'assemblage de primitive, car ils manipulent les primitives fournies par l'étape d'assemblage des primitives. Les ''geometry shaders'' n'ont jamais eu de processeur de shader dédié, car ils ont été introduits avec DirectX 10 et OpenGl 3.2, en même temps que les processeurs de ''shaders'' ont étés unifiés (rendu capable d’exécuter n'importe quel ''shader''). Leur place dans le pipeline graphique est quelque peu étrange. Un point important est que DirectX 10 impose de conserver l'ordre d'envoi des sommets. Si les sommets arrivent dans un certain ordre, il ressortent du ''geometry shader'' dans ce même ordre. Faire ainsi simplifie grandement les choses pour le programmeur. Mais cela impose des contraintes pour le GPU. Les sommets ont beau être envoyés dans l'ordre aux processeurs, certains peuvent être traités plus vite que les autres. Et quand on distribue des sommets sur pleins de processeurs de shader, cela fait que l'ordre de sortie change. Pour corriger cela, les sommets sortants du ''geometry shader'' doivent être remis en ordre. Une première solution est de les mettre en attente dans un second tampon de primitives, pour les remettre en ordre avant la rastérisation. Les primitives sortent des ''geometry shaders'' dans le désordre, sont ajoutées dans le tampon de primitive dans le désordre, mais la rastérisation les consomme dans l'ordre. [[File:Implémentation matérielle des geometry shaders.png|centre|vignette|upright=2.5|Implémentation matérielle des geometry shaders]] Au passage, j'ai menti plus haut en disant que les ''geometry shaders'' fournissent en entrée de 0 à plusieurs primitives : la sortie d'un ''geometry shader'' est un ensemble de sommets, souvent complété par un mini-tampon d'indice indiquant comment assembler ces sommets en primitives. Le résultat est que l'assembleur de primitive doit refaire son travail après le passage d'un ''geometry shader''. Heureusement, la sortie d'un ''geometry shader'' est soit un point, soit une ligne, soit un ''triangle strip'', ce qui simplifie la seconde phase d'assemblage des primitives. Avec les ''geometry shaders'', il y a donc deux phases d'assemblage des primitives : une phase avant, décrite dans la section précédente, et une seconde phase simplifiée après les ''geometry shaders''. Il n'y a pas que la phase d'assemblage de primitives qui est dupliquée : le tampon de primitives l'est aussi. On trouve donc un tampon de primitives à l'entrée des ''geometry shaders'' et un autre à la sortie. ===Les complications liées à la sortie des ''geometry shaders''=== J'ai dit plus haut que le GPu incorpore un second tampon de primitives. Mais sur quelques GPU, les résultats d'un ''geometry shader'' ne passent pas directement par un second tampon de primitives. A la place, ils sont mémorisés en mémoire vidéo, avant d'être lu par l'assemblage de primitives. C'était très lent, mais c'est nécessaire pour une raison qu'on va expliquer immédiatement. Un ''geometry shader'' fournit un résultat très variable en fonction de ses entrées. Pour une même entrée, la sortie peut aller d'une simple primitive à plusieurs dizaines. Le ''geometry shader'' précise cependant un nombre limite de sommets qu'il ne peut pas dépasser en sortie. Il peut ainsi préciser qu'il ne sortira pas plus de 16 sommets, par exemple. Et ce nombre maximal est celui qui est utilisé pour savoir comment organiser le tampon de primitive. Par exemple, si jamais on a un tampon de primitive capable de mémoriser 1024 sommets, celui-ci peut être partitionné en 512 blocs de deux sommets, ou 256 blocs de 4 sommets, 128 blocs de 4 sommets, etc. Pour savoir comment subdiviser le tampon de primitives en parts égales, il n'y a qu'une seule solution : diviser le tampon de primitive par des blocs de taille maximale. Ainsi, si le shader dit qu'il aura en sortie entre 0 et 16 sommets maximum, on doit diviser le tampon en parts de 16 sommets, ce qui fait maximum 1024/16 = 128 instances de shaders maximum. En conséquence, le second tampon de primitives sera sous-utilisé en pratique. Et le principe reste le même si on change les chiffres exacts : chaque instance de shader reçoit une certaine portion du tampon de primitive, égale à la taille du tampon de primitives divisée par ce nombre limite. Vous noterez que la répartition n'est pas dynamique, mais statique. C'est la méthode la plus simple niveau matériel et celle qui coute le moins en circuits, malgré sa mauvaise utilisation, du tampon de primitives. Le problème est que le nombre d'instances exécutables en parallèle est rapidement limité. Une solution à cela est la suivante. Quand un ''geometry shader'' a terminé son travail, il regarde s'il y a de la place dans le second tampon de primitives. Si celui-ci est plein, il attend que de la place se libère. On a donc un processeur de shader qui ne fait rien. les primitives calculées sont juste mémorisées dans les registres en attendant d'être transférées au tampon de primitives. Au pire, on peut espérer qu'une autre instance s'exécute dans un autre ''thread'', grâce aux propriétés de ''multithreading'' matériel. Le nombre de ''geometry shader'' pouvant attendre est alors limité par le nombre de registres du processeur, et la taille des ''shaders''. Avoir beaucoup de registres est alors un avantage ([http://www.joshbarczak.com/blog/?p=667 Why Geometry Shaders Are Slow (Unless you’re Intel)]). Une solution alternative est de mémoriser le résultat des ''geometry shaders'' en mémoire RAM, pour ensuite relire le résultat pour l'envoyer à la rastérisation. Pas besoin de second tampon de primitives, les limitations de nombre de shaders exécutés en parallèle disparaissent. Les processeurs de shaders sont utilisés au maximum, mais le cout en bande passante mémoire est assez élevé. les performances ne sont donc pas franchement meilleures. : Il n'y a pas le même problème avec les ''vertex shaders'' car ils ne font que modifier des sommets : pour N sommets en entrées, ils fourniront N sommets en sortie. Ainsi, si on X processeurs de shaders pouvant traiter Y sommets en même temps avec leurs instructions SIMD, on peut prévoir le nombre de sommets en sortie. Le tampon de primitive est conçu pour encaisser ce nombre de sommets sortants, voire beaucoup plus. Il est rarement un point bloquant en termes de performances. ==Les ''mesh shaders''== <noinclude>[[File:D3D11 Pipeline.svg|vignette|upright=1|Pipeline graphique de Direct x 11.]]</noinclude> Avec l'introduction des ''geometry shaders'' et de la tesselation, le pipeline graphique est devenu très complexe. Plusieurs étages en plus sont ajoutés à sa portion géométrique : un pour les ''geometry shaders'', trois pour la tesselation, et ce en plus des ''vertex shaders'' existants et des étages non-programmables. Le pipeline en question est celui d'Open GL 4 et de DirectX 11. Mais Direct X 12 a simplifié le tout, sous l'impulsion de technologies introduites par AMD et de NVIDIA. AMD a introduit les ''primitive shaders'', NVIDIA a introduit les ''mesh shaders'''' ont été introduit par NVIDIA. Les derniers ont été gardés pour DirectX 12, simplifiant grandement le pipeline. ===Les primitive/mesh shaders=== Les deux solutions de AMD et NVIDIA partent du même principe : elles fusionnent certaines étapes du pipeline. Les ''primitive/mesh shaders'' font disparaitre les étapes d{{'}}''input assembly'' et d'assemblage de primitives, qui sont maintenant gérées par les ''primitive/mesh shaders''. Les ''primitive/mesh shaders'' lisent directement le tampon d'indice et lisent les sommets depuis la VRAM, sans passer par une étape non-programmable. Ils assemblent les primitives eux-mêmes et les envoient directement au rastériseur. Le tout permet des optimisations très intéressantes, comme un ''culling'' précoce. Les ''mesh shaders'' sont des ''shaders'' généralistes, semblables aux ''compute shaders''. Pour rappel, un ''compute shader'' peut lire des données en RAM, exécuter des traitements dessus, et enregistrer les résultats en RAM. Il peut lire ou écrire à des adresses arbitraires, sans limitations. Il n'est pas limité à lire des données consécutives, peut sauter d'une donnée à une autre donnée distante en RAM. Les ''mesh shaders'' sont des variantes des ''compute shaders'', qui n'écrivent pas leur résultat en RAM, mais envoient celui-ci au rastériseur. Plus précisément, ils écrivent leur résultat dans le tampon de primitives. Les ''mesh shaders'' peuvent contourner l'étape d{{'}}''input assembly'' et la remplacer par leur propre code. Pour rappel, l'étape d{{'}}''input assembly'' était non-programmable et gérait des tampons de vertices et d'indices très normés. Les sommets étaient lus soit un par un, soit par paquets de N sommets consécutifs, ce qui était assez rigide. Il n'y avait pas d'accès arbitraire en mémoire RAM comme peuvent le faire les ''compute shaders''. Par contre, un ''mesh shader'' peut accéder aux sommets de la manière qu'il souhaite, ce qui permet d'émuler un ''input assembler'' normal et plus encore. Une autre différence avec les ''vertex shaders'' est qu'ils ne traitent pas forcément des sommets, mais peuvent aussi envoyer des primitives au rastériseur directement. En clair, ils n'ont pas besoin d'une étape de ''primitive assembly'', qu'ils peuvent émuler directement dans le ''shader'' lui-même. Le ''culling'' est lui aussi réalisé par le ''primitive shader'', pas par une unité fixe. Et cela permet de contourner un problème fondamental des ''vertex shaders'' : il fallait que les primitives soient assemblées pour qu'on puisse déterminer si elles sont ou non invisibles. A l'opposé, les ''primitive/mesh shaders'' assemblent les primitives de manière précoce dans le ''primitive/mesh shader'', ce qui permet d'éliminer les primitives invisibles le plus tôt possible. Pour cela, les opérations permettant de déterminer si une primitive est visible sont exécutés en priorité, les autres opérations sont retardées et effectuées le plus tard possible. Ainsi, les calculs pour colorier ou orienter un sommet ne sont pas exécutés si le sommet est invisible. Il y a des différences entre ''primitive'' et ''mesh shaders''. Les ''primitive shaders'' permettent de lire un sommet à la fois, alors que les ''mesh shaders'' permettent de lire des ''batchs'' de plusieurs primitives d'un coup. Ces ''batchs'' de plusieurs primitives sont appelés des meshlets. La différence n'est pas fondamentale : le hardware des cartes AMD, qui gère des ''primitive shaders'', peut regrouper dynamiquement plusieurs instances de ''primitive shaders'' en un seul ''mesh shader'', via les technique de SIMT (une instance de ''primitive shader'' effectue des opérations scalaires, qui peuvent être regroupées en une seule instance SIMD en traitant plusieurs sommets en parallèle). La seule différence est que les ''mesh shaders'' exposent ce comportement au niveau du jeu d'instruction des ''shaders'', les programmeurs en ont conscience. ===Le pipeline géométrique avec les ''primitive/mesh shaders''=== Avec les ''primitive shaders'', l'implémentation exacte dépend de si la tesselation est activée ou non. Si la tesselation n'est pas activée, le ''vertex shader'' et le ''geométry shader'' sont fusionnés en un seul ''primitive shader''. {|class="wikitable" |+ Comparaison entre les pipelines géométriques de DirectX 11 et 12, sans tesselation |- ! DirectX 11 | class="f_rouge" | ''Input assembly'' | ''Vertex shader'' | ''Geometry shader'' | class="f_rouge" | ''Primitive assembly'' |- | colspan="4" | |- ! DirectX 12 | colspan="4" | ''Primitive shader'' (AMD) |} Avec la tesselation activée, les ''geometry shaders'' et les ''domain shaders'' en un seul ''shader''. De même, les ''vertex shaders'' et les ''hull shaders'' sont fusionnés en un seul ''shader'', nommé l{{'}}''amplification shader''. Ainsi, le pipeline graphique est grandement simplifié, avec seulement deux ''shaders'' et un étage fixe, au lieu de quatre ''shaders'' différents. {|class="wikitable" |+ Comparaison entre les pipelines géométriques de DirectX 11 et 12, avec tesselation |- ! DirectX 11 | class="f_rouge" | ''Input assembly'' | ''Vertex shader'' | ''Hull shader'' | class="f_rouge" | Tesselation | ''Domain shader'' | ''Geometry shader'' | class="f_rouge" | ''Primitive assembly'' |- | colspan="7" | |- ! DirectX 12 | colspan="3" | * ''Amplification shader'' (AMD) | class="f_rouge" | Tesselation | colspan="3" | * ''Primitive shader'' (AMD) |} <noinclude> {{NavChapitre | book=Les cartes graphiques | prev=Le pipeline géométrique : évolution | prevText=Le pipeline géométrique : évolution | next=Le rasterizeur | nextText=Le rasterizeur }}{{autocat}} </noinclude> eu5ivx45kbiasybifbuo4vfoqqvg8v2 765158 765157 2026-04-26T20:19:28Z Mewtow 31375 /* Les mesh shaders */ 765158 wikitext text/x-wiki Dans le chapitre précédent, nous avons vu qu'il y a une différence entre le pipeline géométrique des anciennes stations de travail et des ordinateurs personnels. Les premiers tendaient à utiliser des processeurs flottants, programmés avec un ''firmware/microcode'' non-modifiable. Les ordinateurs personnels ont eu commencé avec des circuits géométriques fixe, pour les rendre de plus en plus programmables. Dans ce chapitre, nous allons étudier les circuits géométriques d'un GPU d'ordinateur personnel, et voir comment ils ont évolués dans le temps. ==Le ''vertex pipeline''== Les premières cartes graphiques ne traitaient que des sommets, les primitives n'apparaissaient qu'à l'étape de rastérisation. Leur pipeline a progressivement évolué pour pouvoir exécuter des ''shaders'' sur des primitives, mais ce n'est apparu qu'avec DirectX 10. Avant, les unités géométriques ne géraient que des sommets. Nous allons voir de telles unités géométriques ici. Elles sont composées de trois circuits : l'''input assembly'', l'unité géométrique proprement dit, et l'assemblage des primitives. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | rowspan="2" class="f_rouge" | ''Input assembly'' | ''Transform & Lighting'' | rowspan="2" class="f_rouge" | ''Primitive assembly'' |- | ''Vertex shader'' |} Pour comprendre à quoi servent l'''input assembler'' et l'assemblage de primitives, il faut parler de certaines optimisations présentes sur les cartes graphiques de l'époque. ===Les représentations des maillages : les optimisations=== Les optimisations visaient à réduire la mémoire prise pour les objets 3D. Pour rappel, les objets géométriques et la scène 3D sont mémorisés dans la mémoire vidéo, avec un assemblage de triangles collés les uns aux autres, l'ensemble formant un '''maillage'''. Pour mémoriser un maillage, il suffit d'utiliser une liste de triangles, chaque triangle étant définit par trois sommets consécutifs. Cependant, utiliser cette représentation gaspille beaucoup de mémoire ! [[File:Représentation naive d'un maillage 3D.png|centre|vignette|upright=2|Représentation naive d'un maillage 3D]] [[File:Cube colored.png|vignette|Cube en 3D]] Pour comprendre pourquoi, il faut savoir qu'un sommet est très souvent partagé par plusieurs triangles. Pour comprendre pourquoi, prenons l'exemple du cube de l'image ci-contre. Le sommet rouge du cube appartient aux 3 faces grise, jaune et bleue, et sera présent en trois exemplaires dans le tampon de sommets : un pour la face bleue, un pour la jaune, et un pour la grise. Et si vous croyez que l'exemple du cube n'est pas réaliste, voici un chiffre obtenu empiriquement, par analyse de maillages utilisés dans un JV : en moyenne, un sommet est dupliqué en 6 exemplaires. Pour éviter ce gâchis, les concepteurs d'API et de cartes graphiques ont inventé des représentations pour les maillages, qui visent éliminer cette redondance. Nous les appellerons des '''représentations compressées''', bien que ce terme soit un peu trompeur. Mais dans les faits, il s'agit bien d'une forme de compression de données, bien que très différente de celle utilisée pour compresser un fichier, de la vidéo, du texte ou de l'audio. La liste de triangle est en quelque sorte compressée lors de la création du maillage, puis décompressée par le matériel. Les représentations compressées n'utilisent pas une liste de triangles, mais une liste de sommets. La liste de sommets est mémorisée en mémoire vidéo, et s'appelle le '''tampon de sommets'''. Ainsi, un sommet présent dans plusieurs triangles n'est mémorisé qu'une seule fois, ou presque. Reste à reconstituer les triangles à partir de cette liste de sommets. Et c'est le travail de l'''input assembler'' et l'assemblage de primitive, justement. Mais avant de comprendre ce qu'ils font, nous devons voir les représentations compressées utilisées sur les cartes graphiques de l'époque. Les premières versions d'Open GL et Direct X implémentaient deux représentations compressées : les ''triangle fans'' et celle des ''triangle strips''. Elles ont été remplacées par la représentation indicée, apparue avec Direct X 7 et les versions équivalentes d'Open GL. Nous allons voir cette dernière en premier, car elle est plus simple. La '''représentation indicée''' stocke les triangles et les sommets séparément, avec une liste de triangle séparée de la liste de sommets. Dit comme cela, on ne voit pas vraiment où se trouve le gain en mémoire. Mais il y a une astuce, qui tient à ce qu'on met dans la liste de triangles. Les sommets sont numérotés, le numéro indiquant leur place dans la liste de sommets. Dans la liste de triangles, un triangle est mémorisé non pas par trois sommets consécutifs, mais par trois numéros de sommets. Le numéro est aussi appelé l'indice du sommet, et la liste de triangles est appelée le ''tampon d'indices''. : Le terme '"indice" devrait rappeler quelques chose à ceux qui savent ce qu'est un tableau en programmation. Le résultat est que les sommets ne sont pas dupliqués, mais on doit ajouter un tampon d'indice pour compenser. L'astuce est que l'économie en termes de sommets dépasse largement l'ajout du tampon d'indice. En effet, un indice prend moins de place qu'un sommet. Un sommet demande trois coordonnées, une couleur de sommet, des coordonnées de texture, une normale et bien d'autres attributs de sommets. En comparaison, un indice est un simple numéro, un nombre entier. En moyenne, un sommet prend 10 fois plus de place qu'un indice. Si on fait le compte, au lieu d'avoir N copies d'un sommet, on a juste une seule copie et N indices. L'économie liée à la taille des indices l'emporte. : On pourrait remplacer les indices par des pointeurs, ce qui donnerait un cas particulier d'une structure de données connue sous le nom de vecteur de Liffe. Mais ce n'est pas très pratique et n'est pas utilisé dans le domaine du rendu 3D. Un numéro entier est plus court qu'un pointeur complet. [[File:Représentation indicée d'un maillage 3D.png|centre|vignette|upright=2|Représentation indicée d'un maillage 3D]] Les premières versions d'Open GL et Direct X implémentaient deux représentations compressées : les ''triangle fans'' et celle des ''triangle strips''. Elles sont plus complexes, mais permettent une économie de mémoire encore plus importante. La technique des '''triangles fan''' était la moins utilisée des deux, mais elle est plus simple à expliquer, ce qui fait que je commence avec elle. Elle permet de dessiner des triangles qui partagent un sommet unique, ce qui donne une forme soit circulaire, soit en forme d'éventail. Les ''triangles fans'' sont utiles pour créer des figures comme des cercles, des halos de lumière, etc. Un triangle est définit par le sommet partagé, puis deux sommets. Le sommet partagé n'est présent qu'en un seul exemplaire, et une autre optimisation permet d'optimiser les deux autres sommets. [[File:Triangle fan.png|centre|vignette|upright=2.0|Triangle fan]] Avec cette représentation, le tampon de sommets contient une liste de sommets, qui est interprétée sommet par sommet. Le premier sommet est le sommet partagé par tous les triangles du ''triangle fan''. Le premier triangle est définit par le sommet partagé et deux nouveaux sommets. Les triangles suivants sont eux définit par un seul sommet, pas deux. En effet, deux triangles consécutifs partagent une arête, définie par le sommet partagé et un des deux sommets. Sur les deux sommets, le dernier sommet est celui de l'arête partagée. En faisant ainsi, un triangle est définit par un nouveau sommet, le sommet précédent dans le tampon de sommet, et le sommet partagé. {|class="wikitable" |- ! Tampon de sommet !! Triangle 1 !! Triangle 2 !! Triangle 3 !! Triangle 4 !! Triangle 5 !! Triangle 6 !! Triangle 7 !! ... |- | Sommet 1 || X || X || X || X || X || X || X || X |- | Sommet 2 || X || || || || || || |- | Sommet 3 || X || X || || || || || |- | Sommet 4 || || X || X || || || || |- | Sommet 5 || || || X || X || || || |- | Sommet 6 || || || || X || X || || |- | Sommet 7 || || || || || X || X || |- | Sommet 8 || || || || || || X || X |} La technique des '''triangles strip''' optimise le rendu de triangles placés en série, comme illustré dans le schéma ci-dessous. Notez que deux consécutifs ont deux sommets en commun. L'idée est alors que quand on passe au triangle suivant, on ne précise que le sommet restant, pas les deux sommets en commun. [[File:Triangle strip.svg|centre|vignette|upright=2|Triangle strip]] L'implémentation est assez simple : dans le tampon de sommets, trois sommets consécutifs forment un triangle. Et pour passer d'un triangle au suivant, on ne saute pas de trois sommets, on passe d'un sommet au suivant. {|class="wikitable" |- ! Tampon de sommet !! Triangle 1 !! Triangle 2 !! Triangle 3 !! Triangle 4 !! Triangle 5 !! Triangle 6 !! ... |- | Sommet 1 || X || || || || || |- | Sommet 2 || X || X || || || || |- | Sommet 3 || X || X || X || || || |- | Sommet 4 || || X || X || X || || |- | Sommet 5 || || || X || X || X || |- | Sommet 6 || || || || X || X || X |- | Sommet 7 || || || || || X || X |- | Sommet 8 || || || || || || X |} Les ''triangle fan'' et ''triangle strip'' permettent une économie de mémoire conséquente, comparé à la représentation non-compressée. Au lieu de trois sommets pour chaque triangle, on se retrouve avec un sommet pour chaque triangle, plus les deux premiers sommets. La comparaison avec l'usage d'un tampon d'indice dépend de la taille des indices, mais ''triangle fan'' et ''triangle strip'' sont plus économes niveau mémoire vidéo. Un problème est que les ''triangle strip'' ne permettent pas de représenter tous les modèles 3D, certains ne sont simplement pas compatibles avec cette représentation. Et pour les ''triangle fan'', c'est encore pire ! Cependant, il est souvent possible de ruser, ce qui permet de faire rentrer des modèles non-coopératifs dans un ''triangle strip'', mais quelques sommets sont alors redondants. ===L'''input assembler'' et le tampon d'indice=== Les représentations précédentes ont une influence importante sur le pipeline géométrique. Pour les gérer, il a fallu non seulement modifier l'assemblage de primitives, mais aussi rajouter un circuit juste avant l'unité géométrique : l'''input assembler''. Il charge les sommets depuis la mémoire vidéo, pour les injecter dans le reste du pipeline. [[File:Input assembler.png|centre|vignette|upright=2.0|Input assembler]] Pour faire son travail, il a besoin de l'adresse des données géométriques en mémoire, leur taille et éventuellement du type des données qu'on lui envoie (sommets codées sur 32 bits, 64, 128, etc). En clair, il doit connaitre l'adresse du tampon de sommet et éventuellement celle du tampon d'indice. Et en général, c'est une unité d'accès mémoire un peu particulière, qui contient des circuits assez classiques pour ce genre de circuits : des circuits de calcul d'adresse, des circuits pour commander la mémoire VRAM, un contrôleur mémoire, diverses mémoires tampons, etc. Il procède différemment suivant la représentation utilisée. Il peut lire trois sommets consécutifs avec une représentation non-compressée, il peut lire un tampon d'indice et l'utiliser pour charger les sommets adéquats, il peut lire un sommet à la fois avec les ''triangle fan/strip'', etc. Tout dépend de comment l'unité est configurée. Dans ce qui suit, nous allons étudier un ''input assembler'' qui gère la représentation indicée. Il peut être adapté pour gérer les autres représentations assez simplement. L'idée est que l'''input assembler'' est composé de trois circuits principaux : un qui lit le tampon d'indice, un autre qui lit le tampon de sommets, un dernier qui package les sommets. Le premier lit les indices depuis la mémoire vidéo. Le second récupère l'indice chargé par le premier, et lit le sommet associé dans le tampon de sommets. Ils sont respectivement appelés avec les noms : ''index fetch'' et ''vertex fetch''. Le dernier circuit se contente de formater les sommets pour qu'ils soient compréhensibles par les unités géométriques. [[File:Implémentation matérielle de l'input assembler.png|centre|vignette|upright=2|Implémentation matérielle de l'input assembler.]] Pour les représentations autres qu'indicée, seul le ''vertex fetch'' est utilisé. Il se contente alors de balayer le tampon de sommets dans l'ordre, du premier sommet au dernier. Un vulgaire compteur d'adresse suffit pour cela. Avec la représentation indicée, le circuit d'''index fetch'' est utilisé. Il balaye un tableau d'indices du début à la fin, ce qui fait que le calcul d'adresse est réalisé par un simple compteur d'adresse. Le circuit de ''vertex fetch'' fait des calculs d'adresse un chouilla moins simples, mais qui se contentent de combiner l'adresse du tampon de sommets avec l'indice. Les unités de ''index fetch'' et de ''vertex fetch'' font donc des calculs d'adresse et des accès mémoire. Par contre, les deux circuits peuvent implémenter des mémoires caches, pour améliorer les performances. Vous remarquerez que l’''input assembler'' fait surtout des calculs d'adresse, des lectures en mémoire, et des conversions de format de données. Un processeur de ''vertex shader'' peut faire la même chose, ce qui fait qu'il est possible d'émuler l'''input assembler'' avec un ''vertex shader''. La seule condition, absolument nécessaire, est que le ''vertex shader'' puisse lire des données en mémoire vidéo. Et pas seulement lire des textures, comme le permettent les techniques de ''vertex texturing'', mais de vraies lectures arbitraires, pour lire les tampons de sommet/indice. Cette possibilité est arrivée avec Direct X 10, ce qui fait que l’''input assembler'' peut être émulé par les ''vertex shaders'' à partir de cette version de Direct X. De nos jours, tous les GPUs font à leur sauce. Certains émulent l’''input assembler'' avec des ''shaders'', d'autres non. Ceux qui le font le font en modifiant les ''vertex shaders''. Le ''driver'' du GPU injecte du code dans les ''vertex shaders'', code qui émule l'''input assembler''. ===Les caches de sommets : une optimisation du tampon d'indice=== Idéalement, le ''vertex shader'' doit être exécuté une seule fois par sommet (idem pour son équivalent avec une unité de T&L). Mais quand des sommets sont dupliqués, ce n'est pas le cas. Le problème se comprend bien si on prend une représentation non-compressée, où les sommets sont dupliqués si nécessaires. Le résultat est que les copies d'un même sommet sont toutes lues depuis la mémoire, transformées, éclairées, puis envoyées à l'unité d'assemblage de primitives. En clair : un sommet est lu en VRAM plusieurs fois, et subit des calculs géométriques redondants. Ce qui est un problème. Les représentations compressées permettent de grandement réduire cette redondance. Les ''triangle strip'' et ''triangle fan'' sont de loin les plus efficaces, de ce point de vue : un sommet n'est chargé qu'une seule fois, et n'est traité qu'une seule fois. Du moins, si tout se passe bien. En effet, pour convertir un modèle 3D en ''triangle strip/fan'', il faut parfois ruser, ce qui fait que des sommets sont redondants. Avec la représentation indicée, l'''input assembler'' doit détecter quand un sommet dupliqué a déjà été rencontré. Si un tel sommet dupliqué est détecté, on récupère le sommet déjà calculé, plutôt que de refaire les calculs. Mais cela demande d'ajouter une mémoire cache pour mémoriser les sommets transformés/éclairés. Elle est appelée le '''''Post Transform Cache''''' et il est crucial pour éviter les calculs redondants. L'idée est la suivante : en sortie de l’''index fetch'', un circuit regarde les indices chargés et vérifie s'ils ont déjà été rencontrés. Si l'indice est inconnu, alors on suppose que le sommet associé n'a jamais été rencontré. L'indice est envoyé à l'unité de ''vertex fetch'', le sommet est chargé depuis le tampon de sommet et envoyé à l'unité géométrique. Par contre, si l'indice est reconnu, c'est que le sommet associé a déjà été transformé/éclairé : on lit alors le sommet transformé depuis le ''Post Transform Cache''. Pour détecter un sommet déjà rencontré, rien de plus simple : il suffit de consulter le ''Post Transform Cache''. Une fois un indice chargé, le ''Post Transform Cache'' est consulté pour vérifier s'il a une copie du sommet associé. Le cache répond alors soit en disant qu'il n'a pas le sommet associé, soit il renvoie le sommet transformé. Le ''Post Transform Cache'' est consulté en lui envoyant l'indice du sommet, et potentiellement de quoi identifier le tampon d'indice utilisé. C'est pour ne pas confondre deux sommets appartenant à deux modèles différents mais qui ont le même indice par hasard. Deux solutions pour cela : soit on utilise un identifiant pour le tampon d'indice utilisé (pas une adresse), soit on vide le cache entre deux ''draw call''. Il est vraisemblable que tout soit plus compliqué. En, effet, il faut tenir compte du cas où un sommet est en cours de calcul. Pour gérer ce cas, il est probable que l’''input assembler'' réserve de la place dans ce cache à l'avance. Quand un sommet est envoyé aux unités géométriques, l’''input assembler'' doit réserver de la place dans le cache, en mettant l'indice dans le ''tag'' du cache, et en laissant la ligne de cache vide. Le ''Post Transform Cache'' mémorise les N derniers sommets rencontrés. Elle est souvent qualifiée de mémoire FIFO, mais c'est un intermédiaire entre une mémoire cache du point de vue des lectures, et une mémoire FIFO du point de vue des écritures. Il mémorise entre 16 et 64 sommets, pas plus. Aller au-delà ne sert pas à grand chose, vu que des sommets dupliqués sont très souvent proches en mémoire RAM et sont traités dans une fenêtre temporelle assez petite. [[File:Post-transform cache.png|centre|vignette|upright=2|Post-transform cache]] Le ''Post-transform cache'' se trouve donc en sortie de l'unité d’''index fetch''. Mais serait-il possible d'ajouter un second cache, cette fois-ci pour l'unité de ''vertex fetch'' ? Un tel cache existe lui aussi, et s’appelle le '''''pre-transform cache'''''. Il mémorise les sommets chargés, mais pas encore transformés/éclairés. Il se situe entre l'unité de ''vertex fetch'' et l'unité géométrique. Intuitivement, on se dit qu'il évite de charger un sommet plusieurs fois. Mais ce n'est en réalité qu'un intérêt secondaire, bon à prendre, mais pas primordial. En réalité, il permet de profiter du fait que le ''vertex fetch'' charge les sommets par paquets de 32 à 64 sommets, qui sont copiés dans le cache de sommets. Ainsi, quand on charge un sommet, les 32/64 suivants sont chargés avec et sont disponibles pour l'unité de ''vertex shader'' si celle-ci en a besoin dans le futur, ce qui a de très fortes chances d'être le cas. De plus, il est possible de précharger des lignes de cache : quand le ''vertex fetch'' lit un paquet de sommets, le paquet de sommet est copié dans le cache, mais les paquets suivants peuvent aussi être chargés en avance. Une telle technique de '''préchargement'' permet d'améliorer les performances. [[File:Pre- et Post-transform cache.png|centre|vignette|upright=2|Pre- et Post-transform cache]] Pour résumer, l’''input assembler'' contient deux caches, qui sont collectivement appelés des '''caches de sommets'''. Le ''Post Transform Cache'' a disparu dans certains GPU modernes. Je recommande la lecture de l'article "Revisiting The Vertex Cache : Understanding and Optimizing Vertex Processing on the modern GPU" à ce sujet. Quant au ''Pre Transform Cache'', il a été remplacé par des mémoires caches généralistes, qui ne sont pas spécialisées dans les sommets. ===L'assemblage de primitives=== En sortie des unités géométriques, on a des sommets éclairés et colorisés, pas des triangles. Pour recréer des triangles, on doit lire les sommets dans l'ordre adéquat, par paquets de trois pour obtenir des triangles. C'est le rôle de l''''étape d'assemblage de primitives''' (''primitive assembly''), qui regroupe les sommets appartenant au même triangle, à la même primitive. L'assemblage des primitives est réalisée par un circuit fixe, non-programmable, qui utilise le tampon d'indice pour regrouper les sommets en primitives. Un problème pour l'assemblage de primitives est que les sommets n’arrivent pas dans l'ordre. Il arrive que des sommets soit traités plus vite que les autres, et passent devant. Le pipeline ne peut pas se baser sur l'ordre d'arrivée des sommets, pour regrouper les sommets en triangles. Pour gérer ces temps de calcul variable, le pipeline mémorise les triangles en sortie des unités géométriques et attend que tous les sommets d'un triangles soient disponibles. La méthode pour cela dépend de la représentation utilisée. L'assemblage des primitives ne se passe pas pareil avec les ''triangle strip'', ''triangle fan'', représentation indicée et représentation non-compressées. Avec la représentation non-compressée, l'assemblage de primitives regroupe les triangles par paquets de trois, rien de plus. Mais attention, des triangles consécutifs en mémoire ne sortent pas des unités géométriques l'un à la suite de l'autre. Pour gérer ça, l'''input assembler'' associe, un numéro à chaque triangle, qui indique sa place dans le tampon de sommets, qui est un indice. L'assemblage de primitive regarde ces numéros pour regrouper les triangles. Il attend que trois numéros consécutifs soient disponibles pour assembler le prochain triangle. Pour l'adressage indicé, il procède comme la représentation non-compréssée, sauf qu'il regarde le tampon d'indice. Il lit le tampon d'indice en partant du début, et fait des groupes de trois indices consécutifs. Les sommets sont associés avec leur indice, qui les accompagne lors de leur trajet dans le pipeline géométrique. Une fois qu'ils sortent des unités géométriques, ils sont accumulés dans une mémoire juste avant l'unité de primitive, et l'assemblage de primitive attend que les trois sommets avec les trois indices adéquats soient disponibles. Avec les ''triangle strip'', il mémorise les deux derniers sommets chargés, pour les combiner avec le prochain sommet à charger. L'implémentation matérielle est assez simple : un registre pour mémoriser le premier sommet, une mémoire FIFO pour mémoriser les deux sommets les plus récents. Pour générer un triangle, l'étape d'assemblage de primitive lit le registre et la mémoire FIFO, pour récupérer les trois sommets. Avec les ''triangle fan'', il doit mémoriser le sommet partagé, et le dernier sommet chargé, ce qui demande deux registres. ==Les ''geometry shaders''== Les GPU d'avant DirectX 10, qui n'avaient que les ''vertex shaders'' et ne pouvaient manipuler que des sommets. Depuis DirectX 10, le pipeline graphique a intégré des techniques pour gérer nativement des triangles dans les ''shaders''. Dans ce chapitre, nous allons étudier le pipeline graphique de DirectX 10, DirectX 11 et DirectX 12. L'intérêt est que cela permet de faciliter l'implémentation de techniques de tesselation, sans compter que certaines optimisations deviennent plus simples à effectuer. Dans ce chapitre, nous allons étudier le pipeline graphique de DirectX 10, DirectX 11 et DirectX 12. DirectX 10 et OpenGl 3.2 ont introduit les ''geometry shaders'', juste avant l'étape d'assemblage des primitives. Les ''geometry shaders'' peuvent ajouter, supprimer ou altérer des primitives dans une scène 3D. Un ''geometry shader'' prend en entrée un point, une ligne ou un triangle, donc les trois primitives de base supportées sur les GPU modernes. Il émet en sortie : soit un ''triangle strip'', soit une ''line strip'' (c'est à une ligne ce qu'un d'un ''triangle strip'' est à un triangle) ou un point. Ils n'ont pas été très utilisés, leurs utilisations étant assez limitées. Ils peuvent en théorie être utilisés pour la gestion des ''cubemaps'', le ''shadow volume extrusion'', la génération de particules, et quelques autres effets graphiques. Ils pourraient aussi être utilisés pour faire de la tesselation, mais leurs limitations font que ce n'est pas pratique. Rappelons que les ''geometry shaders'' sont optionnels et que beaucoup de jeux vidéos ou de moteurs de rendu 3D n'en utilisent pas. ===La conservation de l'ordre des sommets entrants et sortants=== Les ''geometry shaders'' sont exécutés après l'assemblage de primitive, car ils manipulent les primitives fournies par l'étape d'assemblage des primitives. Les ''geometry shaders'' n'ont jamais eu de processeur de shader dédié, car ils ont été introduits avec DirectX 10 et OpenGl 3.2, en même temps que les processeurs de ''shaders'' ont étés unifiés (rendu capable d’exécuter n'importe quel ''shader''). Leur place dans le pipeline graphique est quelque peu étrange. Un point important est que DirectX 10 impose de conserver l'ordre d'envoi des sommets. Si les sommets arrivent dans un certain ordre, il ressortent du ''geometry shader'' dans ce même ordre. Faire ainsi simplifie grandement les choses pour le programmeur. Mais cela impose des contraintes pour le GPU. Les sommets ont beau être envoyés dans l'ordre aux processeurs, certains peuvent être traités plus vite que les autres. Et quand on distribue des sommets sur pleins de processeurs de shader, cela fait que l'ordre de sortie change. Pour corriger cela, les sommets sortants du ''geometry shader'' doivent être remis en ordre. Une première solution est de les mettre en attente dans un second tampon de primitives, pour les remettre en ordre avant la rastérisation. Les primitives sortent des ''geometry shaders'' dans le désordre, sont ajoutées dans le tampon de primitive dans le désordre, mais la rastérisation les consomme dans l'ordre. [[File:Implémentation matérielle des geometry shaders.png|centre|vignette|upright=2.5|Implémentation matérielle des geometry shaders]] Au passage, j'ai menti plus haut en disant que les ''geometry shaders'' fournissent en entrée de 0 à plusieurs primitives : la sortie d'un ''geometry shader'' est un ensemble de sommets, souvent complété par un mini-tampon d'indice indiquant comment assembler ces sommets en primitives. Le résultat est que l'assembleur de primitive doit refaire son travail après le passage d'un ''geometry shader''. Heureusement, la sortie d'un ''geometry shader'' est soit un point, soit une ligne, soit un ''triangle strip'', ce qui simplifie la seconde phase d'assemblage des primitives. Avec les ''geometry shaders'', il y a donc deux phases d'assemblage des primitives : une phase avant, décrite dans la section précédente, et une seconde phase simplifiée après les ''geometry shaders''. Il n'y a pas que la phase d'assemblage de primitives qui est dupliquée : le tampon de primitives l'est aussi. On trouve donc un tampon de primitives à l'entrée des ''geometry shaders'' et un autre à la sortie. ===Les complications liées à la sortie des ''geometry shaders''=== J'ai dit plus haut que le GPu incorpore un second tampon de primitives. Mais sur quelques GPU, les résultats d'un ''geometry shader'' ne passent pas directement par un second tampon de primitives. A la place, ils sont mémorisés en mémoire vidéo, avant d'être lu par l'assemblage de primitives. C'était très lent, mais c'est nécessaire pour une raison qu'on va expliquer immédiatement. Un ''geometry shader'' fournit un résultat très variable en fonction de ses entrées. Pour une même entrée, la sortie peut aller d'une simple primitive à plusieurs dizaines. Le ''geometry shader'' précise cependant un nombre limite de sommets qu'il ne peut pas dépasser en sortie. Il peut ainsi préciser qu'il ne sortira pas plus de 16 sommets, par exemple. Et ce nombre maximal est celui qui est utilisé pour savoir comment organiser le tampon de primitive. Par exemple, si jamais on a un tampon de primitive capable de mémoriser 1024 sommets, celui-ci peut être partitionné en 512 blocs de deux sommets, ou 256 blocs de 4 sommets, 128 blocs de 4 sommets, etc. Pour savoir comment subdiviser le tampon de primitives en parts égales, il n'y a qu'une seule solution : diviser le tampon de primitive par des blocs de taille maximale. Ainsi, si le shader dit qu'il aura en sortie entre 0 et 16 sommets maximum, on doit diviser le tampon en parts de 16 sommets, ce qui fait maximum 1024/16 = 128 instances de shaders maximum. En conséquence, le second tampon de primitives sera sous-utilisé en pratique. Et le principe reste le même si on change les chiffres exacts : chaque instance de shader reçoit une certaine portion du tampon de primitive, égale à la taille du tampon de primitives divisée par ce nombre limite. Vous noterez que la répartition n'est pas dynamique, mais statique. C'est la méthode la plus simple niveau matériel et celle qui coute le moins en circuits, malgré sa mauvaise utilisation, du tampon de primitives. Le problème est que le nombre d'instances exécutables en parallèle est rapidement limité. Une solution à cela est la suivante. Quand un ''geometry shader'' a terminé son travail, il regarde s'il y a de la place dans le second tampon de primitives. Si celui-ci est plein, il attend que de la place se libère. On a donc un processeur de shader qui ne fait rien. les primitives calculées sont juste mémorisées dans les registres en attendant d'être transférées au tampon de primitives. Au pire, on peut espérer qu'une autre instance s'exécute dans un autre ''thread'', grâce aux propriétés de ''multithreading'' matériel. Le nombre de ''geometry shader'' pouvant attendre est alors limité par le nombre de registres du processeur, et la taille des ''shaders''. Avoir beaucoup de registres est alors un avantage ([http://www.joshbarczak.com/blog/?p=667 Why Geometry Shaders Are Slow (Unless you’re Intel)]). Une solution alternative est de mémoriser le résultat des ''geometry shaders'' en mémoire RAM, pour ensuite relire le résultat pour l'envoyer à la rastérisation. Pas besoin de second tampon de primitives, les limitations de nombre de shaders exécutés en parallèle disparaissent. Les processeurs de shaders sont utilisés au maximum, mais le cout en bande passante mémoire est assez élevé. les performances ne sont donc pas franchement meilleures. : Il n'y a pas le même problème avec les ''vertex shaders'' car ils ne font que modifier des sommets : pour N sommets en entrées, ils fourniront N sommets en sortie. Ainsi, si on X processeurs de shaders pouvant traiter Y sommets en même temps avec leurs instructions SIMD, on peut prévoir le nombre de sommets en sortie. Le tampon de primitive est conçu pour encaisser ce nombre de sommets sortants, voire beaucoup plus. Il est rarement un point bloquant en termes de performances. ==Les ''mesh shaders''== Avec l'introduction des ''geometry shaders'' et de la tesselation, le pipeline graphique est devenu très complexe. Plusieurs étages en plus sont ajoutés à sa portion géométrique : un pour les ''geometry shaders'', trois pour la tesselation, et ce en plus des ''vertex shaders'' existants et des étages non-programmables. Le pipeline en question est celui d'Open GL 4 et de DirectX 11. Mais Direct X 12 a simplifié le tout, sous l'impulsion de technologies introduites par AMD et de NVIDIA. AMD a introduit les ''primitive shaders'', NVIDIA a introduit les ''mesh shaders'''' ont été introduit par NVIDIA. Les derniers ont été gardés pour DirectX 12, simplifiant grandement le pipeline. ===Les primitive/mesh shaders=== Les deux solutions de AMD et NVIDIA partent du même principe : elles fusionnent certaines étapes du pipeline. Les ''primitive/mesh shaders'' font disparaitre les étapes d{{'}}''input assembly'' et d'assemblage de primitives, qui sont maintenant gérées par les ''primitive/mesh shaders''. Les ''primitive/mesh shaders'' lisent directement le tampon d'indice et lisent les sommets depuis la VRAM, sans passer par une étape non-programmable. Ils assemblent les primitives eux-mêmes et les envoient directement au rastériseur. Le tout permet des optimisations très intéressantes, comme un ''culling'' précoce. Les ''mesh shaders'' sont des ''shaders'' généralistes, semblables aux ''compute shaders''. Pour rappel, un ''compute shader'' peut lire des données en RAM, exécuter des traitements dessus, et enregistrer les résultats en RAM. Il peut lire ou écrire à des adresses arbitraires, sans limitations. Il n'est pas limité à lire des données consécutives, peut sauter d'une donnée à une autre donnée distante en RAM. Les ''mesh shaders'' sont des variantes des ''compute shaders'', qui n'écrivent pas leur résultat en RAM, mais envoient celui-ci au rastériseur. Plus précisément, ils écrivent leur résultat dans le tampon de primitives. Les ''mesh shaders'' peuvent contourner l'étape d{{'}}''input assembly'' et la remplacer par leur propre code. Pour rappel, l'étape d{{'}}''input assembly'' était non-programmable et gérait des tampons de vertices et d'indices très normés. Les sommets étaient lus soit un par un, soit par paquets de N sommets consécutifs, ce qui était assez rigide. Il n'y avait pas d'accès arbitraire en mémoire RAM comme peuvent le faire les ''compute shaders''. Par contre, un ''mesh shader'' peut accéder aux sommets de la manière qu'il souhaite, ce qui permet d'émuler un ''input assembler'' normal et plus encore. Une autre différence avec les ''vertex shaders'' est qu'ils ne traitent pas forcément des sommets, mais peuvent aussi envoyer des primitives au rastériseur directement. En clair, ils n'ont pas besoin d'une étape de ''primitive assembly'', qu'ils peuvent émuler directement dans le ''shader'' lui-même. Le ''culling'' est lui aussi réalisé par le ''primitive shader'', pas par une unité fixe. Et cela permet de contourner un problème fondamental des ''vertex shaders'' : il fallait que les primitives soient assemblées pour qu'on puisse déterminer si elles sont ou non invisibles. A l'opposé, les ''primitive/mesh shaders'' assemblent les primitives de manière précoce dans le ''primitive/mesh shader'', ce qui permet d'éliminer les primitives invisibles le plus tôt possible. Pour cela, les opérations permettant de déterminer si une primitive est visible sont exécutés en priorité, les autres opérations sont retardées et effectuées le plus tard possible. Ainsi, les calculs pour colorier ou orienter un sommet ne sont pas exécutés si le sommet est invisible. Il y a des différences entre ''primitive'' et ''mesh shaders''. Les ''primitive shaders'' permettent de lire un sommet à la fois, alors que les ''mesh shaders'' permettent de lire des ''batchs'' de plusieurs primitives d'un coup. Ces ''batchs'' de plusieurs primitives sont appelés des meshlets. La différence n'est pas fondamentale : le hardware des cartes AMD, qui gère des ''primitive shaders'', peut regrouper dynamiquement plusieurs instances de ''primitive shaders'' en un seul ''mesh shader'', via les technique de SIMT (une instance de ''primitive shader'' effectue des opérations scalaires, qui peuvent être regroupées en une seule instance SIMD en traitant plusieurs sommets en parallèle). La seule différence est que les ''mesh shaders'' exposent ce comportement au niveau du jeu d'instruction des ''shaders'', les programmeurs en ont conscience. ===Le pipeline géométrique avec les ''primitive/mesh shaders''=== Avec les ''primitive shaders'', l'implémentation exacte dépend de si la tesselation est activée ou non. Si la tesselation n'est pas activée, le ''vertex shader'' et le ''geométry shader'' sont fusionnés en un seul ''primitive shader''. {|class="wikitable" |+ Comparaison entre les pipelines géométriques de DirectX 11 et 12, sans tesselation |- ! DirectX 11 | class="f_rouge" | ''Input assembly'' | ''Vertex shader'' | ''Geometry shader'' | class="f_rouge" | ''Primitive assembly'' |- | colspan="4" | |- ! DirectX 12 | colspan="4" | ''Primitive shader'' (AMD) |} Avec la tesselation activée, les ''geometry shaders'' et les ''domain shaders'' en un seul ''shader''. De même, les ''vertex shaders'' et les ''hull shaders'' sont fusionnés en un seul ''shader'', nommé l{{'}}''amplification shader''. Ainsi, le pipeline graphique est grandement simplifié, avec seulement deux ''shaders'' et un étage fixe, au lieu de quatre ''shaders'' différents. {|class="wikitable" |+ Comparaison entre les pipelines géométriques de DirectX 11 et 12, avec tesselation |- ! DirectX 11 | class="f_rouge" | ''Input assembly'' | ''Vertex shader'' | ''Hull shader'' | class="f_rouge" | Tesselation | ''Domain shader'' | ''Geometry shader'' | class="f_rouge" | ''Primitive assembly'' |- | colspan="7" | |- ! DirectX 12 | colspan="3" | * ''Amplification shader'' (AMD) | class="f_rouge" | Tesselation | colspan="3" | * ''Primitive shader'' (AMD) |} <noinclude> {{NavChapitre | book=Les cartes graphiques | prev=Le pipeline géométrique : évolution | prevText=Le pipeline géométrique : évolution | next=Le rasterizeur | nextText=Le rasterizeur }}{{autocat}} </noinclude> by422oxkska3lv2ful1zxg44y0a1q3z 765159 765158 2026-04-26T20:19:39Z Mewtow 31375 /* Les mesh shaders */ 765159 wikitext text/x-wiki Dans le chapitre précédent, nous avons vu qu'il y a une différence entre le pipeline géométrique des anciennes stations de travail et des ordinateurs personnels. Les premiers tendaient à utiliser des processeurs flottants, programmés avec un ''firmware/microcode'' non-modifiable. Les ordinateurs personnels ont eu commencé avec des circuits géométriques fixe, pour les rendre de plus en plus programmables. Dans ce chapitre, nous allons étudier les circuits géométriques d'un GPU d'ordinateur personnel, et voir comment ils ont évolués dans le temps. ==Le ''vertex pipeline''== Les premières cartes graphiques ne traitaient que des sommets, les primitives n'apparaissaient qu'à l'étape de rastérisation. Leur pipeline a progressivement évolué pour pouvoir exécuter des ''shaders'' sur des primitives, mais ce n'est apparu qu'avec DirectX 10. Avant, les unités géométriques ne géraient que des sommets. Nous allons voir de telles unités géométriques ici. Elles sont composées de trois circuits : l'''input assembly'', l'unité géométrique proprement dit, et l'assemblage des primitives. {|class="wikitable" |- ! colspan="4" | Cartes accélératrices PC, avant l'arrivée des ''shaders'' |- | rowspan="2" class="f_rouge" | ''Input assembly'' | ''Transform & Lighting'' | rowspan="2" class="f_rouge" | ''Primitive assembly'' |- | ''Vertex shader'' |} Pour comprendre à quoi servent l'''input assembler'' et l'assemblage de primitives, il faut parler de certaines optimisations présentes sur les cartes graphiques de l'époque. ===Les représentations des maillages : les optimisations=== Les optimisations visaient à réduire la mémoire prise pour les objets 3D. Pour rappel, les objets géométriques et la scène 3D sont mémorisés dans la mémoire vidéo, avec un assemblage de triangles collés les uns aux autres, l'ensemble formant un '''maillage'''. Pour mémoriser un maillage, il suffit d'utiliser une liste de triangles, chaque triangle étant définit par trois sommets consécutifs. Cependant, utiliser cette représentation gaspille beaucoup de mémoire ! [[File:Représentation naive d'un maillage 3D.png|centre|vignette|upright=2|Représentation naive d'un maillage 3D]] [[File:Cube colored.png|vignette|Cube en 3D]] Pour comprendre pourquoi, il faut savoir qu'un sommet est très souvent partagé par plusieurs triangles. Pour comprendre pourquoi, prenons l'exemple du cube de l'image ci-contre. Le sommet rouge du cube appartient aux 3 faces grise, jaune et bleue, et sera présent en trois exemplaires dans le tampon de sommets : un pour la face bleue, un pour la jaune, et un pour la grise. Et si vous croyez que l'exemple du cube n'est pas réaliste, voici un chiffre obtenu empiriquement, par analyse de maillages utilisés dans un JV : en moyenne, un sommet est dupliqué en 6 exemplaires. Pour éviter ce gâchis, les concepteurs d'API et de cartes graphiques ont inventé des représentations pour les maillages, qui visent éliminer cette redondance. Nous les appellerons des '''représentations compressées''', bien que ce terme soit un peu trompeur. Mais dans les faits, il s'agit bien d'une forme de compression de données, bien que très différente de celle utilisée pour compresser un fichier, de la vidéo, du texte ou de l'audio. La liste de triangle est en quelque sorte compressée lors de la création du maillage, puis décompressée par le matériel. Les représentations compressées n'utilisent pas une liste de triangles, mais une liste de sommets. La liste de sommets est mémorisée en mémoire vidéo, et s'appelle le '''tampon de sommets'''. Ainsi, un sommet présent dans plusieurs triangles n'est mémorisé qu'une seule fois, ou presque. Reste à reconstituer les triangles à partir de cette liste de sommets. Et c'est le travail de l'''input assembler'' et l'assemblage de primitive, justement. Mais avant de comprendre ce qu'ils font, nous devons voir les représentations compressées utilisées sur les cartes graphiques de l'époque. Les premières versions d'Open GL et Direct X implémentaient deux représentations compressées : les ''triangle fans'' et celle des ''triangle strips''. Elles ont été remplacées par la représentation indicée, apparue avec Direct X 7 et les versions équivalentes d'Open GL. Nous allons voir cette dernière en premier, car elle est plus simple. La '''représentation indicée''' stocke les triangles et les sommets séparément, avec une liste de triangle séparée de la liste de sommets. Dit comme cela, on ne voit pas vraiment où se trouve le gain en mémoire. Mais il y a une astuce, qui tient à ce qu'on met dans la liste de triangles. Les sommets sont numérotés, le numéro indiquant leur place dans la liste de sommets. Dans la liste de triangles, un triangle est mémorisé non pas par trois sommets consécutifs, mais par trois numéros de sommets. Le numéro est aussi appelé l'indice du sommet, et la liste de triangles est appelée le ''tampon d'indices''. : Le terme '"indice" devrait rappeler quelques chose à ceux qui savent ce qu'est un tableau en programmation. Le résultat est que les sommets ne sont pas dupliqués, mais on doit ajouter un tampon d'indice pour compenser. L'astuce est que l'économie en termes de sommets dépasse largement l'ajout du tampon d'indice. En effet, un indice prend moins de place qu'un sommet. Un sommet demande trois coordonnées, une couleur de sommet, des coordonnées de texture, une normale et bien d'autres attributs de sommets. En comparaison, un indice est un simple numéro, un nombre entier. En moyenne, un sommet prend 10 fois plus de place qu'un indice. Si on fait le compte, au lieu d'avoir N copies d'un sommet, on a juste une seule copie et N indices. L'économie liée à la taille des indices l'emporte. : On pourrait remplacer les indices par des pointeurs, ce qui donnerait un cas particulier d'une structure de données connue sous le nom de vecteur de Liffe. Mais ce n'est pas très pratique et n'est pas utilisé dans le domaine du rendu 3D. Un numéro entier est plus court qu'un pointeur complet. [[File:Représentation indicée d'un maillage 3D.png|centre|vignette|upright=2|Représentation indicée d'un maillage 3D]] Les premières versions d'Open GL et Direct X implémentaient deux représentations compressées : les ''triangle fans'' et celle des ''triangle strips''. Elles sont plus complexes, mais permettent une économie de mémoire encore plus importante. La technique des '''triangles fan''' était la moins utilisée des deux, mais elle est plus simple à expliquer, ce qui fait que je commence avec elle. Elle permet de dessiner des triangles qui partagent un sommet unique, ce qui donne une forme soit circulaire, soit en forme d'éventail. Les ''triangles fans'' sont utiles pour créer des figures comme des cercles, des halos de lumière, etc. Un triangle est définit par le sommet partagé, puis deux sommets. Le sommet partagé n'est présent qu'en un seul exemplaire, et une autre optimisation permet d'optimiser les deux autres sommets. [[File:Triangle fan.png|centre|vignette|upright=2.0|Triangle fan]] Avec cette représentation, le tampon de sommets contient une liste de sommets, qui est interprétée sommet par sommet. Le premier sommet est le sommet partagé par tous les triangles du ''triangle fan''. Le premier triangle est définit par le sommet partagé et deux nouveaux sommets. Les triangles suivants sont eux définit par un seul sommet, pas deux. En effet, deux triangles consécutifs partagent une arête, définie par le sommet partagé et un des deux sommets. Sur les deux sommets, le dernier sommet est celui de l'arête partagée. En faisant ainsi, un triangle est définit par un nouveau sommet, le sommet précédent dans le tampon de sommet, et le sommet partagé. {|class="wikitable" |- ! Tampon de sommet !! Triangle 1 !! Triangle 2 !! Triangle 3 !! Triangle 4 !! Triangle 5 !! Triangle 6 !! Triangle 7 !! ... |- | Sommet 1 || X || X || X || X || X || X || X || X |- | Sommet 2 || X || || || || || || |- | Sommet 3 || X || X || || || || || |- | Sommet 4 || || X || X || || || || |- | Sommet 5 || || || X || X || || || |- | Sommet 6 || || || || X || X || || |- | Sommet 7 || || || || || X || X || |- | Sommet 8 || || || || || || X || X |} La technique des '''triangles strip''' optimise le rendu de triangles placés en série, comme illustré dans le schéma ci-dessous. Notez que deux consécutifs ont deux sommets en commun. L'idée est alors que quand on passe au triangle suivant, on ne précise que le sommet restant, pas les deux sommets en commun. [[File:Triangle strip.svg|centre|vignette|upright=2|Triangle strip]] L'implémentation est assez simple : dans le tampon de sommets, trois sommets consécutifs forment un triangle. Et pour passer d'un triangle au suivant, on ne saute pas de trois sommets, on passe d'un sommet au suivant. {|class="wikitable" |- ! Tampon de sommet !! Triangle 1 !! Triangle 2 !! Triangle 3 !! Triangle 4 !! Triangle 5 !! Triangle 6 !! ... |- | Sommet 1 || X || || || || || |- | Sommet 2 || X || X || || || || |- | Sommet 3 || X || X || X || || || |- | Sommet 4 || || X || X || X || || |- | Sommet 5 || || || X || X || X || |- | Sommet 6 || || || || X || X || X |- | Sommet 7 || || || || || X || X |- | Sommet 8 || || || || || || X |} Les ''triangle fan'' et ''triangle strip'' permettent une économie de mémoire conséquente, comparé à la représentation non-compressée. Au lieu de trois sommets pour chaque triangle, on se retrouve avec un sommet pour chaque triangle, plus les deux premiers sommets. La comparaison avec l'usage d'un tampon d'indice dépend de la taille des indices, mais ''triangle fan'' et ''triangle strip'' sont plus économes niveau mémoire vidéo. Un problème est que les ''triangle strip'' ne permettent pas de représenter tous les modèles 3D, certains ne sont simplement pas compatibles avec cette représentation. Et pour les ''triangle fan'', c'est encore pire ! Cependant, il est souvent possible de ruser, ce qui permet de faire rentrer des modèles non-coopératifs dans un ''triangle strip'', mais quelques sommets sont alors redondants. ===L'''input assembler'' et le tampon d'indice=== Les représentations précédentes ont une influence importante sur le pipeline géométrique. Pour les gérer, il a fallu non seulement modifier l'assemblage de primitives, mais aussi rajouter un circuit juste avant l'unité géométrique : l'''input assembler''. Il charge les sommets depuis la mémoire vidéo, pour les injecter dans le reste du pipeline. [[File:Input assembler.png|centre|vignette|upright=2.0|Input assembler]] Pour faire son travail, il a besoin de l'adresse des données géométriques en mémoire, leur taille et éventuellement du type des données qu'on lui envoie (sommets codées sur 32 bits, 64, 128, etc). En clair, il doit connaitre l'adresse du tampon de sommet et éventuellement celle du tampon d'indice. Et en général, c'est une unité d'accès mémoire un peu particulière, qui contient des circuits assez classiques pour ce genre de circuits : des circuits de calcul d'adresse, des circuits pour commander la mémoire VRAM, un contrôleur mémoire, diverses mémoires tampons, etc. Il procède différemment suivant la représentation utilisée. Il peut lire trois sommets consécutifs avec une représentation non-compressée, il peut lire un tampon d'indice et l'utiliser pour charger les sommets adéquats, il peut lire un sommet à la fois avec les ''triangle fan/strip'', etc. Tout dépend de comment l'unité est configurée. Dans ce qui suit, nous allons étudier un ''input assembler'' qui gère la représentation indicée. Il peut être adapté pour gérer les autres représentations assez simplement. L'idée est que l'''input assembler'' est composé de trois circuits principaux : un qui lit le tampon d'indice, un autre qui lit le tampon de sommets, un dernier qui package les sommets. Le premier lit les indices depuis la mémoire vidéo. Le second récupère l'indice chargé par le premier, et lit le sommet associé dans le tampon de sommets. Ils sont respectivement appelés avec les noms : ''index fetch'' et ''vertex fetch''. Le dernier circuit se contente de formater les sommets pour qu'ils soient compréhensibles par les unités géométriques. [[File:Implémentation matérielle de l'input assembler.png|centre|vignette|upright=2|Implémentation matérielle de l'input assembler.]] Pour les représentations autres qu'indicée, seul le ''vertex fetch'' est utilisé. Il se contente alors de balayer le tampon de sommets dans l'ordre, du premier sommet au dernier. Un vulgaire compteur d'adresse suffit pour cela. Avec la représentation indicée, le circuit d'''index fetch'' est utilisé. Il balaye un tableau d'indices du début à la fin, ce qui fait que le calcul d'adresse est réalisé par un simple compteur d'adresse. Le circuit de ''vertex fetch'' fait des calculs d'adresse un chouilla moins simples, mais qui se contentent de combiner l'adresse du tampon de sommets avec l'indice. Les unités de ''index fetch'' et de ''vertex fetch'' font donc des calculs d'adresse et des accès mémoire. Par contre, les deux circuits peuvent implémenter des mémoires caches, pour améliorer les performances. Vous remarquerez que l’''input assembler'' fait surtout des calculs d'adresse, des lectures en mémoire, et des conversions de format de données. Un processeur de ''vertex shader'' peut faire la même chose, ce qui fait qu'il est possible d'émuler l'''input assembler'' avec un ''vertex shader''. La seule condition, absolument nécessaire, est que le ''vertex shader'' puisse lire des données en mémoire vidéo. Et pas seulement lire des textures, comme le permettent les techniques de ''vertex texturing'', mais de vraies lectures arbitraires, pour lire les tampons de sommet/indice. Cette possibilité est arrivée avec Direct X 10, ce qui fait que l’''input assembler'' peut être émulé par les ''vertex shaders'' à partir de cette version de Direct X. De nos jours, tous les GPUs font à leur sauce. Certains émulent l’''input assembler'' avec des ''shaders'', d'autres non. Ceux qui le font le font en modifiant les ''vertex shaders''. Le ''driver'' du GPU injecte du code dans les ''vertex shaders'', code qui émule l'''input assembler''. ===Les caches de sommets : une optimisation du tampon d'indice=== Idéalement, le ''vertex shader'' doit être exécuté une seule fois par sommet (idem pour son équivalent avec une unité de T&L). Mais quand des sommets sont dupliqués, ce n'est pas le cas. Le problème se comprend bien si on prend une représentation non-compressée, où les sommets sont dupliqués si nécessaires. Le résultat est que les copies d'un même sommet sont toutes lues depuis la mémoire, transformées, éclairées, puis envoyées à l'unité d'assemblage de primitives. En clair : un sommet est lu en VRAM plusieurs fois, et subit des calculs géométriques redondants. Ce qui est un problème. Les représentations compressées permettent de grandement réduire cette redondance. Les ''triangle strip'' et ''triangle fan'' sont de loin les plus efficaces, de ce point de vue : un sommet n'est chargé qu'une seule fois, et n'est traité qu'une seule fois. Du moins, si tout se passe bien. En effet, pour convertir un modèle 3D en ''triangle strip/fan'', il faut parfois ruser, ce qui fait que des sommets sont redondants. Avec la représentation indicée, l'''input assembler'' doit détecter quand un sommet dupliqué a déjà été rencontré. Si un tel sommet dupliqué est détecté, on récupère le sommet déjà calculé, plutôt que de refaire les calculs. Mais cela demande d'ajouter une mémoire cache pour mémoriser les sommets transformés/éclairés. Elle est appelée le '''''Post Transform Cache''''' et il est crucial pour éviter les calculs redondants. L'idée est la suivante : en sortie de l’''index fetch'', un circuit regarde les indices chargés et vérifie s'ils ont déjà été rencontrés. Si l'indice est inconnu, alors on suppose que le sommet associé n'a jamais été rencontré. L'indice est envoyé à l'unité de ''vertex fetch'', le sommet est chargé depuis le tampon de sommet et envoyé à l'unité géométrique. Par contre, si l'indice est reconnu, c'est que le sommet associé a déjà été transformé/éclairé : on lit alors le sommet transformé depuis le ''Post Transform Cache''. Pour détecter un sommet déjà rencontré, rien de plus simple : il suffit de consulter le ''Post Transform Cache''. Une fois un indice chargé, le ''Post Transform Cache'' est consulté pour vérifier s'il a une copie du sommet associé. Le cache répond alors soit en disant qu'il n'a pas le sommet associé, soit il renvoie le sommet transformé. Le ''Post Transform Cache'' est consulté en lui envoyant l'indice du sommet, et potentiellement de quoi identifier le tampon d'indice utilisé. C'est pour ne pas confondre deux sommets appartenant à deux modèles différents mais qui ont le même indice par hasard. Deux solutions pour cela : soit on utilise un identifiant pour le tampon d'indice utilisé (pas une adresse), soit on vide le cache entre deux ''draw call''. Il est vraisemblable que tout soit plus compliqué. En, effet, il faut tenir compte du cas où un sommet est en cours de calcul. Pour gérer ce cas, il est probable que l’''input assembler'' réserve de la place dans ce cache à l'avance. Quand un sommet est envoyé aux unités géométriques, l’''input assembler'' doit réserver de la place dans le cache, en mettant l'indice dans le ''tag'' du cache, et en laissant la ligne de cache vide. Le ''Post Transform Cache'' mémorise les N derniers sommets rencontrés. Elle est souvent qualifiée de mémoire FIFO, mais c'est un intermédiaire entre une mémoire cache du point de vue des lectures, et une mémoire FIFO du point de vue des écritures. Il mémorise entre 16 et 64 sommets, pas plus. Aller au-delà ne sert pas à grand chose, vu que des sommets dupliqués sont très souvent proches en mémoire RAM et sont traités dans une fenêtre temporelle assez petite. [[File:Post-transform cache.png|centre|vignette|upright=2|Post-transform cache]] Le ''Post-transform cache'' se trouve donc en sortie de l'unité d’''index fetch''. Mais serait-il possible d'ajouter un second cache, cette fois-ci pour l'unité de ''vertex fetch'' ? Un tel cache existe lui aussi, et s’appelle le '''''pre-transform cache'''''. Il mémorise les sommets chargés, mais pas encore transformés/éclairés. Il se situe entre l'unité de ''vertex fetch'' et l'unité géométrique. Intuitivement, on se dit qu'il évite de charger un sommet plusieurs fois. Mais ce n'est en réalité qu'un intérêt secondaire, bon à prendre, mais pas primordial. En réalité, il permet de profiter du fait que le ''vertex fetch'' charge les sommets par paquets de 32 à 64 sommets, qui sont copiés dans le cache de sommets. Ainsi, quand on charge un sommet, les 32/64 suivants sont chargés avec et sont disponibles pour l'unité de ''vertex shader'' si celle-ci en a besoin dans le futur, ce qui a de très fortes chances d'être le cas. De plus, il est possible de précharger des lignes de cache : quand le ''vertex fetch'' lit un paquet de sommets, le paquet de sommet est copié dans le cache, mais les paquets suivants peuvent aussi être chargés en avance. Une telle technique de '''préchargement'' permet d'améliorer les performances. [[File:Pre- et Post-transform cache.png|centre|vignette|upright=2|Pre- et Post-transform cache]] Pour résumer, l’''input assembler'' contient deux caches, qui sont collectivement appelés des '''caches de sommets'''. Le ''Post Transform Cache'' a disparu dans certains GPU modernes. Je recommande la lecture de l'article "Revisiting The Vertex Cache : Understanding and Optimizing Vertex Processing on the modern GPU" à ce sujet. Quant au ''Pre Transform Cache'', il a été remplacé par des mémoires caches généralistes, qui ne sont pas spécialisées dans les sommets. ===L'assemblage de primitives=== En sortie des unités géométriques, on a des sommets éclairés et colorisés, pas des triangles. Pour recréer des triangles, on doit lire les sommets dans l'ordre adéquat, par paquets de trois pour obtenir des triangles. C'est le rôle de l''''étape d'assemblage de primitives''' (''primitive assembly''), qui regroupe les sommets appartenant au même triangle, à la même primitive. L'assemblage des primitives est réalisée par un circuit fixe, non-programmable, qui utilise le tampon d'indice pour regrouper les sommets en primitives. Un problème pour l'assemblage de primitives est que les sommets n’arrivent pas dans l'ordre. Il arrive que des sommets soit traités plus vite que les autres, et passent devant. Le pipeline ne peut pas se baser sur l'ordre d'arrivée des sommets, pour regrouper les sommets en triangles. Pour gérer ces temps de calcul variable, le pipeline mémorise les triangles en sortie des unités géométriques et attend que tous les sommets d'un triangles soient disponibles. La méthode pour cela dépend de la représentation utilisée. L'assemblage des primitives ne se passe pas pareil avec les ''triangle strip'', ''triangle fan'', représentation indicée et représentation non-compressées. Avec la représentation non-compressée, l'assemblage de primitives regroupe les triangles par paquets de trois, rien de plus. Mais attention, des triangles consécutifs en mémoire ne sortent pas des unités géométriques l'un à la suite de l'autre. Pour gérer ça, l'''input assembler'' associe, un numéro à chaque triangle, qui indique sa place dans le tampon de sommets, qui est un indice. L'assemblage de primitive regarde ces numéros pour regrouper les triangles. Il attend que trois numéros consécutifs soient disponibles pour assembler le prochain triangle. Pour l'adressage indicé, il procède comme la représentation non-compréssée, sauf qu'il regarde le tampon d'indice. Il lit le tampon d'indice en partant du début, et fait des groupes de trois indices consécutifs. Les sommets sont associés avec leur indice, qui les accompagne lors de leur trajet dans le pipeline géométrique. Une fois qu'ils sortent des unités géométriques, ils sont accumulés dans une mémoire juste avant l'unité de primitive, et l'assemblage de primitive attend que les trois sommets avec les trois indices adéquats soient disponibles. Avec les ''triangle strip'', il mémorise les deux derniers sommets chargés, pour les combiner avec le prochain sommet à charger. L'implémentation matérielle est assez simple : un registre pour mémoriser le premier sommet, une mémoire FIFO pour mémoriser les deux sommets les plus récents. Pour générer un triangle, l'étape d'assemblage de primitive lit le registre et la mémoire FIFO, pour récupérer les trois sommets. Avec les ''triangle fan'', il doit mémoriser le sommet partagé, et le dernier sommet chargé, ce qui demande deux registres. ==Les ''geometry shaders''== Les GPU d'avant DirectX 10, qui n'avaient que les ''vertex shaders'' et ne pouvaient manipuler que des sommets. Depuis DirectX 10, le pipeline graphique a intégré des techniques pour gérer nativement des triangles dans les ''shaders''. Dans ce chapitre, nous allons étudier le pipeline graphique de DirectX 10, DirectX 11 et DirectX 12. L'intérêt est que cela permet de faciliter l'implémentation de techniques de tesselation, sans compter que certaines optimisations deviennent plus simples à effectuer. Dans ce chapitre, nous allons étudier le pipeline graphique de DirectX 10, DirectX 11 et DirectX 12. DirectX 10 et OpenGl 3.2 ont introduit les ''geometry shaders'', juste avant l'étape d'assemblage des primitives. Les ''geometry shaders'' peuvent ajouter, supprimer ou altérer des primitives dans une scène 3D. Un ''geometry shader'' prend en entrée un point, une ligne ou un triangle, donc les trois primitives de base supportées sur les GPU modernes. Il émet en sortie : soit un ''triangle strip'', soit une ''line strip'' (c'est à une ligne ce qu'un d'un ''triangle strip'' est à un triangle) ou un point. Ils n'ont pas été très utilisés, leurs utilisations étant assez limitées. Ils peuvent en théorie être utilisés pour la gestion des ''cubemaps'', le ''shadow volume extrusion'', la génération de particules, et quelques autres effets graphiques. Ils pourraient aussi être utilisés pour faire de la tesselation, mais leurs limitations font que ce n'est pas pratique. Rappelons que les ''geometry shaders'' sont optionnels et que beaucoup de jeux vidéos ou de moteurs de rendu 3D n'en utilisent pas. ===La conservation de l'ordre des sommets entrants et sortants=== Les ''geometry shaders'' sont exécutés après l'assemblage de primitive, car ils manipulent les primitives fournies par l'étape d'assemblage des primitives. Les ''geometry shaders'' n'ont jamais eu de processeur de shader dédié, car ils ont été introduits avec DirectX 10 et OpenGl 3.2, en même temps que les processeurs de ''shaders'' ont étés unifiés (rendu capable d’exécuter n'importe quel ''shader''). Leur place dans le pipeline graphique est quelque peu étrange. Un point important est que DirectX 10 impose de conserver l'ordre d'envoi des sommets. Si les sommets arrivent dans un certain ordre, il ressortent du ''geometry shader'' dans ce même ordre. Faire ainsi simplifie grandement les choses pour le programmeur. Mais cela impose des contraintes pour le GPU. Les sommets ont beau être envoyés dans l'ordre aux processeurs, certains peuvent être traités plus vite que les autres. Et quand on distribue des sommets sur pleins de processeurs de shader, cela fait que l'ordre de sortie change. Pour corriger cela, les sommets sortants du ''geometry shader'' doivent être remis en ordre. Une première solution est de les mettre en attente dans un second tampon de primitives, pour les remettre en ordre avant la rastérisation. Les primitives sortent des ''geometry shaders'' dans le désordre, sont ajoutées dans le tampon de primitive dans le désordre, mais la rastérisation les consomme dans l'ordre. [[File:Implémentation matérielle des geometry shaders.png|centre|vignette|upright=2.5|Implémentation matérielle des geometry shaders]] Au passage, j'ai menti plus haut en disant que les ''geometry shaders'' fournissent en entrée de 0 à plusieurs primitives : la sortie d'un ''geometry shader'' est un ensemble de sommets, souvent complété par un mini-tampon d'indice indiquant comment assembler ces sommets en primitives. Le résultat est que l'assembleur de primitive doit refaire son travail après le passage d'un ''geometry shader''. Heureusement, la sortie d'un ''geometry shader'' est soit un point, soit une ligne, soit un ''triangle strip'', ce qui simplifie la seconde phase d'assemblage des primitives. Avec les ''geometry shaders'', il y a donc deux phases d'assemblage des primitives : une phase avant, décrite dans la section précédente, et une seconde phase simplifiée après les ''geometry shaders''. Il n'y a pas que la phase d'assemblage de primitives qui est dupliquée : le tampon de primitives l'est aussi. On trouve donc un tampon de primitives à l'entrée des ''geometry shaders'' et un autre à la sortie. ===Les complications liées à la sortie des ''geometry shaders''=== J'ai dit plus haut que le GPu incorpore un second tampon de primitives. Mais sur quelques GPU, les résultats d'un ''geometry shader'' ne passent pas directement par un second tampon de primitives. A la place, ils sont mémorisés en mémoire vidéo, avant d'être lu par l'assemblage de primitives. C'était très lent, mais c'est nécessaire pour une raison qu'on va expliquer immédiatement. Un ''geometry shader'' fournit un résultat très variable en fonction de ses entrées. Pour une même entrée, la sortie peut aller d'une simple primitive à plusieurs dizaines. Le ''geometry shader'' précise cependant un nombre limite de sommets qu'il ne peut pas dépasser en sortie. Il peut ainsi préciser qu'il ne sortira pas plus de 16 sommets, par exemple. Et ce nombre maximal est celui qui est utilisé pour savoir comment organiser le tampon de primitive. Par exemple, si jamais on a un tampon de primitive capable de mémoriser 1024 sommets, celui-ci peut être partitionné en 512 blocs de deux sommets, ou 256 blocs de 4 sommets, 128 blocs de 4 sommets, etc. Pour savoir comment subdiviser le tampon de primitives en parts égales, il n'y a qu'une seule solution : diviser le tampon de primitive par des blocs de taille maximale. Ainsi, si le shader dit qu'il aura en sortie entre 0 et 16 sommets maximum, on doit diviser le tampon en parts de 16 sommets, ce qui fait maximum 1024/16 = 128 instances de shaders maximum. En conséquence, le second tampon de primitives sera sous-utilisé en pratique. Et le principe reste le même si on change les chiffres exacts : chaque instance de shader reçoit une certaine portion du tampon de primitive, égale à la taille du tampon de primitives divisée par ce nombre limite. Vous noterez que la répartition n'est pas dynamique, mais statique. C'est la méthode la plus simple niveau matériel et celle qui coute le moins en circuits, malgré sa mauvaise utilisation, du tampon de primitives. Le problème est que le nombre d'instances exécutables en parallèle est rapidement limité. Une solution à cela est la suivante. Quand un ''geometry shader'' a terminé son travail, il regarde s'il y a de la place dans le second tampon de primitives. Si celui-ci est plein, il attend que de la place se libère. On a donc un processeur de shader qui ne fait rien. les primitives calculées sont juste mémorisées dans les registres en attendant d'être transférées au tampon de primitives. Au pire, on peut espérer qu'une autre instance s'exécute dans un autre ''thread'', grâce aux propriétés de ''multithreading'' matériel. Le nombre de ''geometry shader'' pouvant attendre est alors limité par le nombre de registres du processeur, et la taille des ''shaders''. Avoir beaucoup de registres est alors un avantage ([http://www.joshbarczak.com/blog/?p=667 Why Geometry Shaders Are Slow (Unless you’re Intel)]). Une solution alternative est de mémoriser le résultat des ''geometry shaders'' en mémoire RAM, pour ensuite relire le résultat pour l'envoyer à la rastérisation. Pas besoin de second tampon de primitives, les limitations de nombre de shaders exécutés en parallèle disparaissent. Les processeurs de shaders sont utilisés au maximum, mais le cout en bande passante mémoire est assez élevé. les performances ne sont donc pas franchement meilleures. : Il n'y a pas le même problème avec les ''vertex shaders'' car ils ne font que modifier des sommets : pour N sommets en entrées, ils fourniront N sommets en sortie. Ainsi, si on X processeurs de shaders pouvant traiter Y sommets en même temps avec leurs instructions SIMD, on peut prévoir le nombre de sommets en sortie. Le tampon de primitive est conçu pour encaisser ce nombre de sommets sortants, voire beaucoup plus. Il est rarement un point bloquant en termes de performances. ==Les ''mesh shaders''== Avec l'introduction des ''geometry shaders'' et de la tesselation, le pipeline graphique est devenu très complexe. Mais Direct X 12 a simplifié le tout, sous l'impulsion de technologies introduites par AMD et de NVIDIA. AMD a introduit les ''primitive shaders'', NVIDIA a introduit les ''mesh shaders'''' ont été introduit par NVIDIA. Les derniers ont été gardés pour DirectX 12, simplifiant grandement le pipeline. ===Les primitive/mesh shaders=== Les deux solutions de AMD et NVIDIA partent du même principe : elles fusionnent certaines étapes du pipeline. Les ''primitive/mesh shaders'' font disparaitre les étapes d{{'}}''input assembly'' et d'assemblage de primitives, qui sont maintenant gérées par les ''primitive/mesh shaders''. Les ''primitive/mesh shaders'' lisent directement le tampon d'indice et lisent les sommets depuis la VRAM, sans passer par une étape non-programmable. Ils assemblent les primitives eux-mêmes et les envoient directement au rastériseur. Le tout permet des optimisations très intéressantes, comme un ''culling'' précoce. Les ''mesh shaders'' sont des ''shaders'' généralistes, semblables aux ''compute shaders''. Pour rappel, un ''compute shader'' peut lire des données en RAM, exécuter des traitements dessus, et enregistrer les résultats en RAM. Il peut lire ou écrire à des adresses arbitraires, sans limitations. Il n'est pas limité à lire des données consécutives, peut sauter d'une donnée à une autre donnée distante en RAM. Les ''mesh shaders'' sont des variantes des ''compute shaders'', qui n'écrivent pas leur résultat en RAM, mais envoient celui-ci au rastériseur. Plus précisément, ils écrivent leur résultat dans le tampon de primitives. Les ''mesh shaders'' peuvent contourner l'étape d{{'}}''input assembly'' et la remplacer par leur propre code. Pour rappel, l'étape d{{'}}''input assembly'' était non-programmable et gérait des tampons de vertices et d'indices très normés. Les sommets étaient lus soit un par un, soit par paquets de N sommets consécutifs, ce qui était assez rigide. Il n'y avait pas d'accès arbitraire en mémoire RAM comme peuvent le faire les ''compute shaders''. Par contre, un ''mesh shader'' peut accéder aux sommets de la manière qu'il souhaite, ce qui permet d'émuler un ''input assembler'' normal et plus encore. Une autre différence avec les ''vertex shaders'' est qu'ils ne traitent pas forcément des sommets, mais peuvent aussi envoyer des primitives au rastériseur directement. En clair, ils n'ont pas besoin d'une étape de ''primitive assembly'', qu'ils peuvent émuler directement dans le ''shader'' lui-même. Le ''culling'' est lui aussi réalisé par le ''primitive shader'', pas par une unité fixe. Et cela permet de contourner un problème fondamental des ''vertex shaders'' : il fallait que les primitives soient assemblées pour qu'on puisse déterminer si elles sont ou non invisibles. A l'opposé, les ''primitive/mesh shaders'' assemblent les primitives de manière précoce dans le ''primitive/mesh shader'', ce qui permet d'éliminer les primitives invisibles le plus tôt possible. Pour cela, les opérations permettant de déterminer si une primitive est visible sont exécutés en priorité, les autres opérations sont retardées et effectuées le plus tard possible. Ainsi, les calculs pour colorier ou orienter un sommet ne sont pas exécutés si le sommet est invisible. Il y a des différences entre ''primitive'' et ''mesh shaders''. Les ''primitive shaders'' permettent de lire un sommet à la fois, alors que les ''mesh shaders'' permettent de lire des ''batchs'' de plusieurs primitives d'un coup. Ces ''batchs'' de plusieurs primitives sont appelés des meshlets. La différence n'est pas fondamentale : le hardware des cartes AMD, qui gère des ''primitive shaders'', peut regrouper dynamiquement plusieurs instances de ''primitive shaders'' en un seul ''mesh shader'', via les technique de SIMT (une instance de ''primitive shader'' effectue des opérations scalaires, qui peuvent être regroupées en une seule instance SIMD en traitant plusieurs sommets en parallèle). La seule différence est que les ''mesh shaders'' exposent ce comportement au niveau du jeu d'instruction des ''shaders'', les programmeurs en ont conscience. ===Le pipeline géométrique avec les ''primitive/mesh shaders''=== Avec les ''primitive shaders'', l'implémentation exacte dépend de si la tesselation est activée ou non. Si la tesselation n'est pas activée, le ''vertex shader'' et le ''geométry shader'' sont fusionnés en un seul ''primitive shader''. {|class="wikitable" |+ Comparaison entre les pipelines géométriques de DirectX 11 et 12, sans tesselation |- ! DirectX 11 | class="f_rouge" | ''Input assembly'' | ''Vertex shader'' | ''Geometry shader'' | class="f_rouge" | ''Primitive assembly'' |- | colspan="4" | |- ! DirectX 12 | colspan="4" | ''Primitive shader'' (AMD) |} Avec la tesselation activée, les ''geometry shaders'' et les ''domain shaders'' en un seul ''shader''. De même, les ''vertex shaders'' et les ''hull shaders'' sont fusionnés en un seul ''shader'', nommé l{{'}}''amplification shader''. Ainsi, le pipeline graphique est grandement simplifié, avec seulement deux ''shaders'' et un étage fixe, au lieu de quatre ''shaders'' différents. {|class="wikitable" |+ Comparaison entre les pipelines géométriques de DirectX 11 et 12, avec tesselation |- ! DirectX 11 | class="f_rouge" | ''Input assembly'' | ''Vertex shader'' | ''Hull shader'' | class="f_rouge" | Tesselation | ''Domain shader'' | ''Geometry shader'' | class="f_rouge" | ''Primitive assembly'' |- | colspan="7" | |- ! DirectX 12 | colspan="3" | * ''Amplification shader'' (AMD) | class="f_rouge" | Tesselation | colspan="3" | * ''Primitive shader'' (AMD) |} <noinclude> {{NavChapitre | book=Les cartes graphiques | prev=Le pipeline géométrique : évolution | prevText=Le pipeline géométrique : évolution | next=Le rasterizeur | nextText=Le rasterizeur }}{{autocat}} </noinclude> 1bwempnvrjjokcrspsrq6f3kfwrtz5w Le mouvement Wikimédia/Les platesformes Wiki 0 79271 765190 764401 2026-04-27T07:51:13Z Lionel Scheepmans 20012 765190 wikitext text/x-wiki <noinclude>{{Le mouvement Wikimédia}}</noinclude> Un [[w:Wiki|wiki]], ou un [[w:Moteur_de_wiki|moteur de wiki]], est un logiciel que l'on installe sur un serveur informatique pour permettre la création d’un site web éditable et configurable à l’aide d’un simple navigateur. Plus précisément, c’est un [[w:Système_de_gestion_de_contenu|système de gestion de contenu]], dans lequel le code [[Le langage HTML|HTML]], [[Le langage CSS|CSS]], [[Programmation JavaScript|JavaScript]] et [[v:Lua|Lua]], ainsi que certains paramètres, peuvent être modifiés par tous les internautes. Cela peut se faire en se connectant à un compte utilisateur, afin de bénéficier des droits de modification et d’administration qui lui sont accordés, ou en utilisant la configuration attribuée par défaut aux personnes non connectées. Sur les pages web d'un wiki, chaque modification provoque un nouvel enregistrement complet du [[w:Code_source|code source]] qui la compose. De la sorte, il est toujours possible, à partir d’une page reprenant l’[[w:Historique_(informatique)|historique]] des modifications, de rétablir l'une de ses anciennes versions. Grâce à ce système, on peut ainsi savoir quelle personne, ou quelle [[w:Adresse_IP|adresse IP]] est à l’origine d’un changement, et même voir l’endroit où la modification a été faite, et à quel moment celle-ci a été réalisée. [[Fichier:Ward_Cunningham_1.jpg|alt=Logo du logiciel MediaWiki, le logiciel Wiki utilisé par les projets Wikimédia et dont le développement est soutenu par la fondation Wikimédia.|gauche|vignette|<small>Figure 12. Ward Cunningham en 2011.</small>|300x300px]] Le premier logiciel Wiki, qui portait le nom de [[w:fr: WikiWikiWeb|WikiWikiWeb]], a été créé et placé sous licence libre GPL par [[w:fr: Ward Cunningham|Ward Cunningham]] en mars 1995<ref>{{Lien web|auteur=Wiki.c2|titre=Wiki Wiki Web Faq|url=http://web.archive.org/web/20170106225231/http://wiki.c2.com/?WikiWikiWebFaq}}.</ref>. Grâce à la licence, d’autres programmes wiki ont vu le jour en copiant ou s’inspirant du code source de WikiWikiWeb, ou des autres projets wiki qui l'avaient fait auparavant. Cette émulation récursive qui donna naissance à toute une panoplie de projets wiki, est donc à nouveau, une belle illustration des retombées positives, que peut susciter l'application d'une licence libre. Parmi les différents logiciels Wiki disponibles, [[w:en:UseModWiki|UseModWiki]] fut choisi par la société [[w:fr: Bomis|Bomis]] qui finança la création du premier projet Wikipédia en anglais. C'était un choix judicieux, car l’éclatement de la [[w:Bulle_Internet|bulle spéculative d’Internet]] en fin des années 2000, confrontait l'entreprise à de grosses difficultés financières. Un programme gratuit, simple d’utilisation et peu gourmand en ressources informatiques, convenait donc parfaitement dans ce cadre. UseModWiki fut par après remplacé par un autre moteur de Wiki sans nom, mais plus performant et toujours produit sous licence libre<ref>{{Lien web|langue=|auteur=Brion Vibber|titre=MediaWiki's big code & usability code & usability push|url=http://web.archive.org/web/20120517072350/http://leuksman.com/images/8/80/Brion-fosdem2009.pdf|site=Leuksman|date=2009|consulté le=}}.</ref>. Ce dernier fut ensuite amélioré par plusieurs programmeurs, dont Brion Vibber, le premier employé de la [[w:fr: Fondation Wikimédia|Fondation Wikimédia]], avant d’être finalement intitulé [[MediaWiki pour débutants|MediaWiki]]. Avec l’aide de nouveaux employés et des bénévoles actifs sur le site [[mw:Mediawiki|mediawiki.org]], ce système de gestion de contenu finit par apparaitre en tête du classement des wikis les plus utilisés<ref>{{Lien web|auteur=Wiki.c2|titre=Top Ten Wiki Engines|url=https://web.archive.org/web/20201127014153/http://wiki.c2.com/?TopTenWikiEngines|consulté le=}}.</ref>. Grâce à la licence libre, des milliers d'autres personnes et projets ont pu en effet développer des sites Web, sans nécessairement faire partie du mouvement Wikimédia<ref>{{Lien web|langue=|auteur=MediaWiki|titre=Main page|url=https://web.archive.org/web/20201203135522/https://www.mediawiki.org/wiki/MediaWiki|site=|date=|consulté le=}}.</ref>. Ce succès a par ailleurs justifié l'organisation de rassemblements annuels entre 2016 et 2020<ref>{{Lien web|titre=Category:EMWCon|url=https://web.archive.org/web/20200319063245/https://www.mediawiki.org/wiki/Category:EMWCon|site=|date=|consulté le=|auteur=MediaWiki}}.</ref>, entre personnes et organisations qui utilisent le programme, pour discuter de son développement et de ses usages<ref>{{Lien web|langue=|auteur=David Strine|titre=MediaWiki is the software that underpins Wikipedia. This conference shows all the other ways it can be used|url=https://web.archive.org/web/20200313181919/https://wikimediafoundation.org/news/2019/05/01/mediawiki-is-the-software-that-underpins-wikipedia-this-conference-shows-all-the-other-ways-it-can-be-used/|site=Wikimedia Foundation News|lieu=|éditeur=|date=1 May 2019|consulté le=}}.</ref>. Ceci étant dit, il existe dans la [[w:fr:Liste de logiciels wiki|liste des Wikis]] d’autres logiciels libres intéressants, tel que [[w:Dokuwiki|DokuWiki]], qui fut rendu populaire par sa simplicité d’installation et d’usage. Mais jusqu’à ce jour, seul MediaWiki semble suffisamment stable et puissant pour permettre le développement optimal de l’ensemble des projets Wikimédia. Avec parmi ceux-ci, bien sûr, Wikipédia, l'encyclopédie libre et universelle, dont nous allons enfin découvrir la mise en place dans ce prochain chapitre. {{AutoCat}} ptinc1jjwv3r998xdkfrw4dceno32z1 Le mouvement Wikimédia/Le mouvement du logiciel libre 0 79318 765175 764826 2026-04-27T06:16:23Z Lionel Scheepmans 20012 765175 wikitext text/x-wiki <noinclude>{{Le mouvement Wikimédia}}</noinclude> L’un des premiers épisodes de la préhistoire de Wikipédia et du mouvement Wikimédia débuta en septembre 1983, lorsqu’un programmeur du ''[[w:fr:Massachusetts Institute of Technology|Massachusetts Institute of Technology]]'', appelé [[w:fr:Richard Stallman|Richard Stallman]], déposa un message sur la liste de diffusion net.unix-wizards. C’était un appel d’aide pour la création de [[w:Projet GNU|GNU]], un nouveau [[w:fr:Système d'exploitation|système d’exploitation]] qui devait réunir une suite de programmes que tout le monde pourrait utiliser librement sur son ordinateur personnel<ref>{{Ouvrage|langue=|prénom1=Richard M|nom1=Stallman|prénom2=Sam|nom2=Williams|titre=Richard Stallman et la révolution du logiciel libre - Une biographie autorisée|éditeur=Eyrolles|date=2013|oclc=708380925|lire en ligne=https://framabook.org/docs/stallman/framabook6_stallman_v1_gnu-fdl.pdf|consulté le=}}.</ref>. Dans son message transmis via [[w:Arpanet|ARPANET]], le premier réseau informatique à grande échelle qui précéda Internet, Stallman s’exprimait de la sorte<ref>{{Lien web|langue=|auteur=Richard Stallman|titre=Système d'exploitation GNU – Annonce initiale|url=https://web.archive.org/web/20010106133800/http://www.gnu.org:80/gnu/initial-announcement.fr.html|site=GNU|date=3 décembre 2000|consulté le=}}.</ref> : <blockquote> Je considère comme une [[w:Règle d'or|règle d’or]] que si j’apprécie un programme je dois le partager avec d’autres personnes qui l’apprécient. Je ne peux pas en bonne conscience signer un accord de non-divulgation ni un accord de licence de logiciel. Afin de pouvoir continuer à utiliser les ordinateurs sans violer mes principes, j’ai décidé de rassembler une quantité suffisante de logiciels libres, de manière à pouvoir m’en tirer sans aucun logiciel qui ne soit pas libre. </blockquote> Le projet de Stallman, qui reçut le soutien nécessaire à son accomplissement, marqua ainsi le début de l’[[w:Histoire_du_logiciel_libre|histoire du logiciel libre]]. Quant à la quantité d’aide fournie, elle permet de croire que Richard Stallman n’était pas seul à voir l’arrivée des [[w:Logiciel propriétaire|logiciels propriétaires]] d’un mauvais œil. Car pour les membres du projet GNU et du mouvement du logiciel libre en général, un bon programme informatique doit respecter ces quatre libertés fondamentales<ref>{{Lien web|langue=|auteur=Karl Pradène|titre=Qu'est-ce que le logiciel libre ?|url=https://web.archive.org/web/20000511101640/http://www.gnu.org/philosophy/free-sw.fr.html|site=GNU|date=6 mai 2000|consulté le=}}.</ref> : <blockquote> 1. La liberté d’exécuter le programme, pour tous les usages. 2. La liberté d’étudier le fonctionnement du programme, et de l’adapter à vos besoins. 3. La liberté de redistribuer des copies, donc d’aider votre voisin. 4. La liberté d’améliorer le programme, et de publier vos améliorations, pour en faire profiter toute la communauté. </blockquote> [[w:Histoire_du_logiciel_libre|Lors de l'apparition du logiciel libre]], le marché de l’informatique était de fait en pleine mutation. L'habituel partage des codes informatiques entre les rares étudiants ou chercheurs qui bénéficiaient d’un accès à un ordinateur faisait l'objet d'une remise en question. Ce changement faisait notamment suite au [[w:Copyright_Act_(1976)|Copyright Act]] de 1976, une nouvelle loi qui autorisait l'application d'un [[w:Droit_d'auteur|droit d'auteur]] sur le code informatique, et donc qui permettait d'en interdire le partage ou la réutilisation sans autorisation. Des [[w:Clause_de_confidentialité|clauses de confidentialité]] ont ainsi fait leur apparition, pendant que les employés des firmes informatiques étaient nouvellement soumis à des contrats de confidentialité. C'était la fin de l’entraide et de la solidarité pratiquées chez les pionniers de l’informatique. À sa place s'installaient la concurrence et la compétitivité, bien connues dans le système capitaliste marchand. [[Fichier:Commodore64withdisk.jpg|alt=Commodore 64 avec disquette et lecteur|gauche|vignette|<small>Figure 4. Commodore 64 avec disquette et lecteur.</small>|300x300px]] Cette mutation coïncidait avec l’arrivée des premiers ordinateurs de taille réduite. Grâce à l’apparition des premiers [[w:Circuits_intégrés|circuits intégrés⁣⁣]], les premiers exemplaires avaient en effet été créés par l’industrie aérospatiale au début des années 1960. Cependant, il fallut attendre le début des années 1980 pour que le prix d’un ordinateur soit suffisamment bas pour en faire un [[w:Bien_de_grande_consommation|bien de grande consommation]]. C’est ainsi qu’en 1982, le [[w:Commodore 64|Commodore 64]] entrait dans le [[w:Livre_Guinness_des_records|livre Guinness des records]], avec plus de 17 millions d’exemplaires vendus dans le monde<ref>{{Lien web|langue=|auteur=Brandon Griggs|titre=The Commodore 64, that '80 s computer icon, lives again|url=https://web.archive.org/web/20200706161515/http://edition.cnn.com/2011/TECH/gaming.gadgets/05/09/commodore.64.reborn|site=CNN|date=May 9, 2011|consulté le=}}.</ref>. Juste avant cela, en 1981, l’''[[w:fr:IBM PC|IBM Personal Computer]]'' avait déjà fait son apparition, en proposant une [[w:Architecture_(informatique)|architecture]] ouverte qui allait servir de modèle pour toute une gamme d’ordinateurs que l’on désigne toujours aujourd’hui par l’acronyme « PC ». Pour faire fonctionner ses nouveaux modèles d'ordinateurs, la société IBM avait confié à l’entreprise [[w:Microsoft|Microsoft]], créée en 1975, la mission de les équiper d’un système d’exploitation. Le contrat signé entre les deux firmes fut une véritable aubaine pour le fournisseur des programmes informatiques. Car sans s'en apercevoir, et sans jamais anticiper que son matériel serait cloné à grande échelle, celle-ci avait en effet permis à Microsoft d'établir un monopole dans la vente de logiciels. Cela fut condamné pour [[w:Abus_de_position_dominante|abus de position dominante]]<ref name="Combier_2018_01_24">{{Lien web|langue=fr|auteur=Étienne Combier|titre=Abus de position dominante : les plus grosses amendes de la Commission européenne|url=https://web.archive.org/web/20230511110018/https://www.lesechos.fr/2018/01/abus-de-position-dominante-les-plus-grosses-amendes-de-la-commission-europeenne-982719|périodique=[[w:Les Échos|Les Échos]]|date=2018-01-24|consulté le=}}.</ref> et [[w:Vente_liée_de_logiciels_avec_du_matériel_informatique|vente liée du logiciel avec le matériel informatique]]<ref>{{Lien web|langue=fr|auteur=Marc Rees|titre=Pourquoi la justice européenne a sanctuarisé la vente liée PC et OS|url=https://web.archive.org/web/20230209112015/https://www.nextinpact.com/article/23625/101268-la-justice-europeenne-sanctuarise-vente-liee-pc-et-os|site=nextinpact.com|éditeur=[[w:Next INpact|Next INpact]]|date=2016-07-09|consulté le=}}.</ref>, mais sans pour autant empêcher [[w:Bill_Gates|Bill Gates]], le principal actionnaire de Microsoft, d'être l'homme le plus riche du monde en 1994. [[Fichier:GNU_and_Tux.svg|alt=Mascotte du projet GNU à gauche et du projet Linux à droite.|vignette|<small>Figure 5. À gauche la mascotte du projet GNU ; à droite celle du projet Linux, appelée Tux.</small>]] Toutefois, pendant que Microsoft renforçait sa position dominante, un nouvel événement majeur allait marquer l’histoire du logiciel libre. Celui-ci fut de nouveau déclenché par un appel à contribution, qui fut cette fois posté le vingt-cinq août 1991 par un jeune étudiant en informatique de 21 ans, appelé [[w:fr:Linus Torvalds|Linus Torvalds]]. Via le système de messagerie [[w:fr:Usenet|Usenet]], son message avait été posté dans une liste de diffusion consacrée au système d’exploitation [[w:fr:Minix|Minix]], une sorte d’[[w:UNIX|UNIX]] simplifié et développé dans un but didactique, par le programmeur [[w:fr:Andrew Tanenbaum|Andrew Tanenbaum]]. Loin d’imaginer que cela ferait de lui une nouvelle célébrité dans le monde du Libre<ref>{{Ouvrage|langue=|prénom1=Linus|nom1=Torvalds|prénom2=David|nom2=Diamond|prénom3=Olivier|nom3=Engler|titre=Il était une fois Linux|éditeur=Osman Eyrolles Multimédia|date=2001|isbn=978-2-7464-0321-5|oclc=48059105}}.</ref>, Torvalds entama son message par le paragraphe suivant<ref>{{Ouvrage|langue=|prénom1=Linus|nom1=Torvalds|prénom2=David|nom2=Diamond|titre=Just for fun : the story of an accidental revolutionary|éditeur=HarperBusiness|date=2002|isbn=978-0-06-662073-2|oclc=1049937833}}.</ref> : <blockquote> Je fais un système d’exploitation (gratuit) (juste un hobby, ne sera pas grand et professionnel comme gnu) pour les clones 386 (486) AT. Ce projet est en cours depuis avril et commence à se préparer. J’aimerais avoir un retour sur ce que les gens aiment ou n’aiment pas dans minix, car mon système d’exploitation lui ressemble un peu (même disposition physique du système de fichiers (pour des raisons pratiques) entre autres choses)<ref>Texte original avant sa traduction par www.deepl.com/translator : « ''I'm doing a (free) operating system (just a hobby, won't be big and professional like gnu) for 386(486) AT clones. This has been brewing since april, and is starting to get ready. I'd like any feedback on things people like/dislike in minix, as my OS resembles it somewhat (same physical layout of the file-system (due to practical reasons)among other things) ».''</ref>. </blockquote> Bien qu’il fût présenté comme un passe-temps, le projet qui répondait au nom de « [[w:fr:Noyau Linux|Linux]] », fut rapidement soutenu par des milliers de programmeurs du monde entier, avant de devenir la pièce manquante du projet GNU. En effet, les contributeurs au projet de Stallman n’avaient pas encore terminé l’écriture du code informatique du [[w:noyau_de_système_d'exploitation|noyau]] [[w:GNU Hurd|Hurd]], alors qu'il était censé établir la communication entre la [[w:Suite_logicielle|suite logicielle]] produite par GNU et le [[w:Matériel informatique|matériel informatique]]. C'est donc la fusion des codes produits par les projets GNU et Linux qui permit la création du premier système complet, stable et entièrement libre baptisé [[w:GNU/Linux|GNU/Linux]]. [[Fichier:Debian-OpenLogo.svg|gauche|vignette|<small>Figure 6. Logo du système d’exploitation Debian.</small>|264x264px]] Au départ de ce nouveau système informatique, de nombreuses variantes, que l’on nomme communément « [[w:Distribution_Linux|distributions]] », furent créées par des programmeurs de tous horizons. L’une de celles-ci s’intitule [[w:fr:Debian|Debian]] et tire sa réputation d'être la seule qui est simultanément libre, gratuite et produite par une communauté sans lien direct avec une société commerciale<ref>{{Ouvrage|langue=|auteur=|prénom1=Christophe|nom1=Lazaro|titre=La liberte logicielle|passage=|lieu=|éditeur=Academia Bruylant|collection=Anthropologie Prospective|date=2012|pages totales=56|isbn=978-2-87209-861-3|oclc=1104281978}}.</ref>. Ce qui n'a pas empêché pour autant que le code de ce système informatique soit récupéré par plus de 150 distributions dérivées. Quant à la fiabilité du système Debian, elle se confirme par son usage au sein de nombreuses entreprises et organisations, à l’image de la [[w:Wikimedia_Foundation|Fondation Wikimédia]] qui l’utilise sur [[m:Wikimedia_servers/fr|ses serveurs]] pour héberger les projets qu'elle supporte<ref>{{Lien web|langue=|auteur=Méta-Wiki|titre=Serveurs Wikimedia|url=https://web.archive.org/web/20251113214321/https://meta.wikimedia.org/wiki/Wikimedia_servers/fr|site=|date=|consulté le=}}.</ref>. Grâce à la naissance des logiciels libres, le mouvement Wikimédia a donc la possibilité de faire tourner ses serveurs informatiques, avec un système d’exploitation fiable, libre et gratuit. Comme son [[w:Code_source|code source⁣⁣]] est ouvert, cela permet aussi à la Fondation Wikimédia de le modifier pour répondre aux besoins spécifiques du mouvement. À la suite de quoi, et selon les règles formulées par la [[w:Communauté_du_logiciel_libre|communauté du logiciel libre]], les modifications faites par la Fondation deviennent à leur tour, gratuitement et librement, utilisables par d’autres personnes ou organismes. À ce premier héritage reçu par le mouvement Wikimédia s’ajoute ensuite une innovation méthodologique, toujours en provenance des logiciels libres. Dans son article ''[[w:La_Cathédrale_et_le_Bazar|La Cathédrale et le Bazar]]''<ref>{{Ouvrage|langue=|auteur=|prénom1=Eric Steven|nom1=Raymond|titre=Cathedral and the bazaar|titre original=Cathedral and the bazaar|traduction titre=La cathédrale et le bazar|passage=|lieu=|éditeur=SnowBall Publishing|date=2010|pages totales=|isbn=978-1-60796-228-1|oclc=833142152|lire en ligne=}}.</ref>, [[w:Eric_Raymond|Eric Raymond]] mobilise en effet le terme « [[w:Cathédrale|cathédrale]] » pour désigner le mode de production des logiciels propriétaires, en opposition au mot « [[w:fr:Bazar|bazar]] », qu'il utilise pour qualifier le mode de développement des logiciels libres. D’un côté, il décrit une organisation pyramidale, rigide et statutairement hiérarchisée, comme on peut la voir souvent au sein des entreprises. Tandis que de l’autre, il parle d’une organisation horizontale, flexible et peu hiérarchisée, qu’il a lui-même expérimentée en adoptant le style de développement de Linus Torvalds, à savoir : « distribuez vite et souvent, déléguez tout ce que vous pouvez déléguer, soyez ouvert jusqu’à la promiscuité »<ref>{{Lien web|langue=|auteur=Eric S. Raymond|traducteur=Sébastien Blondeel|titre=La cathédrale et le bazar|url=https://web.archive.org/web/20200203054716/http://www.linux-france.org/article/these/cathedrale-bazar/cathedrale-bazar-1.html|site=Linux France|lieu=|date=1998|consulté le=}}.</ref>. À l’instar de la métaphore du quartier numérique présentée dans le précédent chapitre, cette manière de décrire les projets open source nous aide donc ici à mieux comprendre ce qui se passe dans le mouvement Wikimédia. D'un côté, on retrouve effectivement cette « ouverture jusqu’à la promiscuité », dans le libre accès accordé aux projets Wikimédia, alors que de l'autre, tout le monde peut participer aux projets Wikimédia, qu'ils soient en ligne ou hors ligne. Ces deux observations corroborent donc l’existence d’un deuxième héritage, en provenance du mouvement du logiciel libre. Néanmoins, il nous reste encore à découvrir un phénomène négligé par Eric Raymond durant ses observations, et qui a pourtant une importance considérable dans l'histoire de la révolution numérique. Il s’agit là de l’apparition de la licence libre, de la philosophie qu'elle sous-tend, et de la [[w:Culture_libre|culture libre]] dont elle fut à l’origine. {{AutoCat}} rax3dhbmoseowhjhzisviqv5pkesavf 765176 765175 2026-04-27T06:26:08Z Lionel Scheepmans 20012 765176 wikitext text/x-wiki <noinclude>{{Le mouvement Wikimédia}}</noinclude> L’un des premiers épisodes de la préhistoire de Wikipédia et du mouvement Wikimédia débuta en septembre 1983, lorsqu’un programmeur du ''[[w:fr:Massachusetts Institute of Technology|Massachusetts Institute of Technology]]'', appelé [[w:fr:Richard Stallman|Richard Stallman]], déposa un message sur la liste de diffusion net.unix-wizards. C’était un appel d’aide pour la création de [[w:Projet GNU|GNU]], un nouveau [[w:fr:Système d'exploitation|système d’exploitation]] qui devait réunir une suite de programmes que tout le monde pourrait utiliser librement sur son ordinateur personnel<ref>{{Ouvrage|langue=|prénom1=Richard M|nom1=Stallman|prénom2=Sam|nom2=Williams|titre=Richard Stallman et la révolution du logiciel libre - Une biographie autorisée|éditeur=Eyrolles|date=2013|oclc=708380925|lire en ligne=https://framabook.org/docs/stallman/framabook6_stallman_v1_gnu-fdl.pdf|consulté le=}}.</ref>. Dans son message transmis via [[w:Arpanet|ARPANET]], le premier réseau informatique à grande échelle qui précéda Internet, Stallman s’exprimait de la sorte<ref>{{Lien web|langue=|auteur=Richard Stallman|titre=Système d'exploitation GNU – Annonce initiale|url=https://web.archive.org/web/20010106133800/http://www.gnu.org:80/gnu/initial-announcement.fr.html|site=GNU|date=3 décembre 2000|consulté le=}}.</ref> : <blockquote> Je considère comme une [[w:Règle d'or|règle d’or]] que si j’apprécie un programme je dois le partager avec d’autres personnes qui l’apprécient. Je ne peux pas en bonne conscience signer un accord de non-divulgation ni un accord de licence de logiciel. Afin de pouvoir continuer à utiliser les ordinateurs sans violer mes principes, j’ai décidé de rassembler une quantité suffisante de logiciels libres, de manière à pouvoir m’en tirer sans aucun logiciel qui ne soit pas libre. </blockquote> Le projet de Stallman, qui reçut le soutien nécessaire à son accomplissement, marqua ainsi le début de l’[[w:Histoire_du_logiciel_libre|histoire du logiciel libre]]. Quant à la quantité d’aide fournie, elle permet de croire que Richard Stallman n’était pas seul à voir l’arrivée des [[w:Logiciel propriétaire|logiciels propriétaires]] d’un mauvais œil. Car pour les membres du projet GNU et du mouvement du logiciel libre en général, un bon programme informatique doit respecter ces quatre libertés fondamentales<ref>{{Lien web|langue=|auteur=Karl Pradène|titre=Qu'est-ce que le logiciel libre ?|url=https://web.archive.org/web/20000511101640/http://www.gnu.org/philosophy/free-sw.fr.html|site=GNU|date=6 mai 2000|consulté le=}}.</ref> : <blockquote> 1. La liberté d’exécuter le programme, pour tous les usages. 2. La liberté d’étudier le fonctionnement du programme, et de l’adapter à vos besoins. 3. La liberté de redistribuer des copies, donc d’aider votre voisin. 4. La liberté d’améliorer le programme, et de publier vos améliorations, pour en faire profiter toute la communauté. </blockquote> [[w:Histoire_du_logiciel_libre|Lors de l'apparition du logiciel libre]], le marché de l’informatique était de fait en pleine mutation. L'habituel partage des codes informatiques entre les rares étudiants ou chercheurs qui bénéficiaient d’un accès à un ordinateur faisait l'objet d'une remise en question. Ce changement faisait notamment suite au [[w:Copyright_Act_(1976)|Copyright Act]] de 1976, une nouvelle loi qui autorisait l'application d'un [[w:Droit_d'auteur|droit d'auteur]] sur le code informatique, et donc qui permettait d'en interdire le partage ou la réutilisation sans autorisation. Des [[w:Clause_de_confidentialité|clauses de confidentialité]] ont ainsi fait leur apparition, pendant que les employés des firmes informatiques étaient nouvellement soumis à des contrats de confidentialité. C'était la fin de l’entraide et de la solidarité pratiquées chez les pionniers de l’informatique. À sa place s'installaient la concurrence et la compétitivité, bien connues dans le système capitaliste marchand. [[Fichier:Commodore64withdisk.jpg|alt=Commodore 64 avec disquette et lecteur|gauche|vignette|<small>Figure 4. Commodore 64 avec disquette et lecteur.</small>|300x300px]] Cette mutation coïncidait avec l’arrivée des premiers ordinateurs de taille réduite. Grâce à l’apparition des premiers [[w:Circuits_intégrés|circuits intégrés⁣⁣]], les premiers exemplaires avaient en effet été créés par l’industrie aérospatiale au début des années 1960. Cependant, il fallut attendre le début des années 1980 pour que le prix d’un ordinateur soit suffisamment bas pour en faire un [[w:Bien_de_grande_consommation|bien de grande consommation]]. C’est ainsi qu’en 1982, le [[w:Commodore 64|Commodore 64]] entrait dans le [[w:Livre_Guinness_des_records|livre Guinness des records]], avec plus de 17 millions d’exemplaires vendus dans le monde<ref>{{Lien web|langue=|auteur=Brandon Griggs|titre=The Commodore 64, that '80 s computer icon, lives again|url=https://web.archive.org/web/20200706161515/http://edition.cnn.com/2011/TECH/gaming.gadgets/05/09/commodore.64.reborn|site=CNN|date=May 9, 2011|consulté le=}}.</ref>. Juste avant cela, en 1981, l’''[[w:fr:IBM PC|IBM Personal Computer]]'' avait déjà fait son apparition, en proposant une [[w:Architecture_(informatique)|architecture]] ouverte qui allait servir de modèle pour toute une gamme d’ordinateurs que l’on désigne toujours aujourd’hui par l’acronyme « PC ». Pour faire fonctionner ses nouveaux modèles d'ordinateurs, la société IBM avait confié à l’entreprise [[w:Microsoft|Microsoft]], créée en 1975, la mission de les équiper d’un système d’exploitation. Le contrat signé entre les deux firmes fut une véritable aubaine pour le fournisseur des programmes informatiques. Car sans s'en apercevoir, et sans jamais anticiper que son matériel serait cloné à grande échelle, celle-ci avait en effet permis à Microsoft d'établir un monopole dans la vente de logiciels. Cela fut condamné pour [[w:Abus_de_position_dominante|abus de position dominante]]<ref name="Combier_2018_01_24">{{Lien web|langue=fr|auteur=Étienne Combier|titre=Abus de position dominante : les plus grosses amendes de la Commission européenne|url=https://web.archive.org/web/20230511110018/https://www.lesechos.fr/2018/01/abus-de-position-dominante-les-plus-grosses-amendes-de-la-commission-europeenne-982719|périodique=[[w:Les Échos|Les Échos]]|date=2018-01-24|consulté le=}}.</ref> et [[w:Vente_liée_de_logiciels_avec_du_matériel_informatique|vente liée du logiciel avec le matériel informatique]]<ref>{{Lien web|langue=fr|auteur=Marc Rees|titre=Pourquoi la justice européenne a sanctuarisé la vente liée PC et OS|url=https://web.archive.org/web/20230209112015/https://www.nextinpact.com/article/23625/101268-la-justice-europeenne-sanctuarise-vente-liee-pc-et-os|site=nextinpact.com|éditeur=[[w:Next INpact|Next INpact]]|date=2016-07-09|consulté le=}}.</ref>, mais sans pour autant empêcher [[w:Bill_Gates|Bill Gates]], le principal actionnaire de Microsoft, d'être l'homme le plus riche du monde en 1994. [[Fichier:GNU_and_Tux.svg|alt=Mascotte du projet GNU à gauche et du projet Linux à droite.|vignette|<small>Figure 5. À gauche la mascotte du projet GNU ; à droite celle du projet Linux, appelée Tux.</small>]] Toutefois, pendant que Microsoft renforçait sa position dominante, un nouvel événement majeur allait marquer l’histoire du logiciel libre. Celui-ci fut de nouveau déclenché par un appel à contribution, qui fut cette fois posté le vingt-cinq août 1991 par un jeune étudiant en informatique de 21 ans, appelé [[w:fr:Linus Torvalds|Linus Torvalds]]. Via le système de messagerie [[w:fr:Usenet|Usenet]], sa demande avait été posté dans une liste de diffusion consacrée au système d’exploitation [[w:fr:Minix|Minix]], une sorte d’[[w:UNIX|UNIX]] simplifié et développé dans un but didactique, par le programmeur [[w:fr:Andrew Tanenbaum|Andrew Tanenbaum]]. Loin d’imaginer que cela ferait de lui une nouvelle célébrité dans le monde du Libre<ref>{{Ouvrage|langue=|prénom1=Linus|nom1=Torvalds|prénom2=David|nom2=Diamond|prénom3=Olivier|nom3=Engler|titre=Il était une fois Linux|éditeur=Osman Eyrolles Multimédia|date=2001|isbn=978-2-7464-0321-5|oclc=48059105}}.</ref>, Torvalds entama son message par le paragraphe suivant<ref>{{Ouvrage|langue=|prénom1=Linus|nom1=Torvalds|prénom2=David|nom2=Diamond|titre=Just for fun : the story of an accidental revolutionary|éditeur=HarperBusiness|date=2002|isbn=978-0-06-662073-2|oclc=1049937833}}.</ref> : <blockquote> Je fais un système d’exploitation (gratuit) (juste un hobby, ne sera pas grand et professionnel comme gnu) pour les clones 386 (486) AT. Ce projet est en cours depuis avril et commence à se préparer. J’aimerais avoir un retour sur ce que les gens aiment ou n’aiment pas dans minix, car mon système d’exploitation lui ressemble un peu (même disposition physique du système de fichiers (pour des raisons pratiques) entre autres choses)<ref>Texte original avant sa traduction par www.deepl.com/translator : « ''I'm doing a (free) operating system (just a hobby, won't be big and professional like gnu) for 386(486) AT clones. This has been brewing since april, and is starting to get ready. I'd like any feedback on things people like/dislike in minix, as my OS resembles it somewhat (same physical layout of the file-system (due to practical reasons)among other things) ».''</ref>. </blockquote> Bien qu’il fût présenté comme un passe-temps, le projet qui répondait au nom de « [[w:fr:Noyau Linux|Linux]] », fut rapidement soutenu par des milliers de programmeurs du monde entier, avant de devenir la pièce manquante du projet GNU. En effet, les contributeurs au projet de Stallman n’avaient pas encore terminé l’écriture du code informatique du [[w:noyau_de_système_d'exploitation|noyau]] [[w:GNU Hurd|Hurd]], alors qu'il était censé établir la communication entre la [[w:Suite_logicielle|suite logicielle]] produite par GNU et le [[w:Matériel informatique|matériel informatique]]. C'est donc la fusion des codes produits par les projets GNU et Linux qui permit la création du premier système complet, stable et entièrement libre baptisé [[w:GNU/Linux|GNU/Linux]]. [[Fichier:Debian-OpenLogo.svg|gauche|vignette|<small>Figure 6. Logo du système d’exploitation Debian.</small>|264x264px]] Au départ de ce nouveau système informatique, de nombreuses variantes, que l’on nomme communément « [[w:Distribution_Linux|distributions]] », furent créées par des programmeurs de tous horizons. L’une de celles-ci s’intitule [[w:fr:Debian|Debian]] et tire sa réputation d'être la seule qui est simultanément libre, gratuite et produite par une communauté sans lien direct avec une société commerciale<ref>{{Ouvrage|langue=|auteur=|prénom1=Christophe|nom1=Lazaro|titre=La liberte logicielle|passage=|lieu=|éditeur=Academia Bruylant|collection=Anthropologie Prospective|date=2012|pages totales=56|isbn=978-2-87209-861-3|oclc=1104281978}}.</ref>. Ce qui n'a pas empêché pour autant que le code de ce système informatique soit récupéré par plus de 150 distributions dérivées. Quant à la fiabilité du système Debian, elle se confirme par son usage au sein de nombreuses entreprises et organisations, à l’image de la [[w:Wikimedia_Foundation|Fondation Wikimédia]] qui l’utilise sur [[m:Wikimedia_servers/fr|ses serveurs]] pour héberger les projets qu'elle supporte<ref>{{Lien web|langue=|auteur=Méta-Wiki|titre=Serveurs Wikimedia|url=https://web.archive.org/web/20251113214321/https://meta.wikimedia.org/wiki/Wikimedia_servers/fr|site=|date=|consulté le=}}.</ref>. Grâce à la naissance des logiciels libres, le mouvement Wikimédia a donc la possibilité de faire tourner ses serveurs informatiques, avec un système d’exploitation fiable, libre et gratuit. Comme son [[w:Code_source|code source⁣⁣]] est ouvert, cela permet aussi à la Fondation Wikimédia de le modifier pour répondre aux besoins spécifiques du mouvement. À la suite de quoi, et selon les règles formulées par la [[w:Communauté_du_logiciel_libre|communauté du logiciel libre]], les modifications faites par la Fondation deviennent à leur tour, gratuitement et librement, utilisables par d’autres personnes ou organismes. À ce premier héritage reçu par le mouvement Wikimédia s’ajoute ensuite une innovation méthodologique, toujours en provenance des logiciels libres. Dans son article ''[[w:La_Cathédrale_et_le_Bazar|La Cathédrale et le Bazar]]''<ref>{{Ouvrage|langue=|auteur=|prénom1=Eric Steven|nom1=Raymond|titre=Cathedral and the bazaar|titre original=Cathedral and the bazaar|traduction titre=La cathédrale et le bazar|passage=|lieu=|éditeur=SnowBall Publishing|date=2010|pages totales=|isbn=978-1-60796-228-1|oclc=833142152|lire en ligne=}}.</ref>, [[w:Eric_Raymond|Eric Raymond]] mobilise en effet le terme « [[w:Cathédrale|cathédrale]] » pour désigner le mode de production des logiciels propriétaires, en opposition au mot « [[w:fr:Bazar|bazar]] », qu'il utilise pour qualifier le mode de développement des logiciels libres. D’un côté, il décrit une organisation pyramidale, rigide et statutairement hiérarchisée, comme on peut la voir souvent au sein des entreprises. Tandis que de l’autre, il parle d’une organisation horizontale, flexible et peu hiérarchisée, qu’il a lui-même expérimentée en adoptant le style de développement de Linus Torvalds, à savoir : « distribuez vite et souvent, déléguez tout ce que vous pouvez déléguer, soyez ouvert jusqu’à la promiscuité »<ref>{{Lien web|langue=|auteur=Eric S. Raymond|traducteur=Sébastien Blondeel|titre=La cathédrale et le bazar|url=https://web.archive.org/web/20200203054716/http://www.linux-france.org/article/these/cathedrale-bazar/cathedrale-bazar-1.html|site=Linux France|lieu=|date=1998|consulté le=}}.</ref>. À l’instar de la métaphore du quartier numérique présentée dans le précédent chapitre, cette manière de décrire les projets open source nous aide donc ici à mieux comprendre ce qui se passe dans le mouvement Wikimédia. D'un côté, on retrouve effectivement cette « ouverture jusqu’à la promiscuité », dans le libre accès accordé aux projets Wikimédia, alors que de l'autre, tout le monde peut participer aux projets Wikimédia, qu'ils soient en ligne ou hors ligne. Ces deux observations corroborent donc l’existence d’un deuxième héritage, en provenance du mouvement du logiciel libre. Néanmoins, il nous reste encore à découvrir un phénomène négligé par Eric Raymond durant ses observations, et qui a pourtant une importance considérable dans l'histoire de la révolution numérique. Il s’agit là de l’apparition de la licence libre, de la philosophie qu'elle sous-tend, et de la [[w:Culture_libre|culture libre]] dont elle fut à l’origine. {{AutoCat}} ppljvo35uu9fv0dhv31d8m6qa7f4tib 765177 765176 2026-04-27T06:33:20Z Lionel Scheepmans 20012 765177 wikitext text/x-wiki <noinclude>{{Le mouvement Wikimédia}}</noinclude> L’un des premiers épisodes de la préhistoire de Wikipédia et du mouvement Wikimédia débuta en septembre 1983, lorsqu’un programmeur du ''[[w:fr:Massachusetts Institute of Technology|Massachusetts Institute of Technology]]'', appelé [[w:fr:Richard Stallman|Richard Stallman]], déposa un message sur la liste de diffusion net.unix-wizards. C’était un appel d’aide pour la création de [[w:Projet GNU|GNU]], un nouveau [[w:fr:Système d'exploitation|système d’exploitation]] qui devait réunir une suite de programmes que tout le monde pourrait utiliser librement sur son ordinateur personnel<ref>{{Ouvrage|langue=|prénom1=Richard M|nom1=Stallman|prénom2=Sam|nom2=Williams|titre=Richard Stallman et la révolution du logiciel libre - Une biographie autorisée|éditeur=Eyrolles|date=2013|oclc=708380925|lire en ligne=https://framabook.org/docs/stallman/framabook6_stallman_v1_gnu-fdl.pdf|consulté le=}}.</ref>. Dans son message transmis via [[w:Arpanet|ARPANET]], le premier réseau informatique à grande échelle qui précéda Internet, Stallman s’exprimait de la sorte<ref>{{Lien web|langue=|auteur=Richard Stallman|titre=Système d'exploitation GNU – Annonce initiale|url=https://web.archive.org/web/20010106133800/http://www.gnu.org:80/gnu/initial-announcement.fr.html|site=GNU|date=3 décembre 2000|consulté le=}}.</ref> : <blockquote> Je considère comme une [[w:Règle d'or|règle d’or]] que si j’apprécie un programme je dois le partager avec d’autres personnes qui l’apprécient. Je ne peux pas en bonne conscience signer un accord de non-divulgation ni un accord de licence de logiciel. Afin de pouvoir continuer à utiliser les ordinateurs sans violer mes principes, j’ai décidé de rassembler une quantité suffisante de logiciels libres, de manière à pouvoir m’en tirer sans aucun logiciel qui ne soit pas libre. </blockquote> Le projet de Stallman, qui reçut le soutien nécessaire à son accomplissement, marqua ainsi le début de l’[[w:Histoire_du_logiciel_libre|histoire du logiciel libre]]. Quant à la quantité d’aide fournie, elle permet de croire que Richard Stallman n’était pas seul à voir l’arrivée des [[w:Logiciel propriétaire|logiciels propriétaires]] d’un mauvais œil. Car pour les membres du projet GNU et du mouvement du logiciel libre en général, un bon programme informatique doit respecter ces quatre libertés fondamentales<ref>{{Lien web|langue=|auteur=Karl Pradène|titre=Qu'est-ce que le logiciel libre ?|url=https://web.archive.org/web/20000511101640/http://www.gnu.org/philosophy/free-sw.fr.html|site=GNU|date=6 mai 2000|consulté le=}}.</ref> : <blockquote> 1. La liberté d’exécuter le programme, pour tous les usages. 2. La liberté d’étudier le fonctionnement du programme, et de l’adapter à vos besoins. 3. La liberté de redistribuer des copies, donc d’aider votre voisin. 4. La liberté d’améliorer le programme, et de publier vos améliorations, pour en faire profiter toute la communauté. </blockquote> [[w:Histoire_du_logiciel_libre|Lors de l'apparition du logiciel libre]], le marché de l’informatique était de fait en pleine mutation. L'habituel partage des codes informatiques entre les rares étudiants ou chercheurs qui bénéficiaient d’un accès à un ordinateur faisait l'objet d'une remise en question. Ce changement faisait notamment suite au [[w:Copyright_Act_(1976)|Copyright Act]] de 1976, une nouvelle loi qui autorisait l'application d'un [[w:Droit_d'auteur|droit d'auteur]] sur le code informatique, et donc qui permettait d'en interdire le partage ou la réutilisation sans autorisation. Des [[w:Clause_de_confidentialité|clauses de confidentialité]] ont ainsi fait leur apparition, pendant que les employés des firmes informatiques étaient nouvellement soumis à des contrats de confidentialité. C'était la fin de l’entraide et de la solidarité pratiquées chez les pionniers de l’informatique. À sa place s'installaient la concurrence et la compétitivité, bien connues dans le système capitaliste marchand. [[Fichier:Commodore64withdisk.jpg|alt=Commodore 64 avec disquette et lecteur|gauche|vignette|<small>Figure 4. Commodore 64 avec disquette et lecteur.</small>|300x300px]] Cette mutation coïncidait avec l’arrivée des premiers ordinateurs de taille réduite. Grâce à l’apparition des premiers [[w:Circuits_intégrés|circuits intégrés⁣⁣]], les premiers exemplaires avaient en effet été créés par l’industrie aérospatiale au début des années 1960. Cependant, il fallut attendre le début des années 1980 pour que le prix d’un ordinateur soit suffisamment bas pour en faire un [[w:Bien_de_grande_consommation|bien de grande consommation]]. C’est ainsi qu’en 1982, le [[w:Commodore 64|Commodore 64]] entrait dans le [[w:Livre_Guinness_des_records|livre Guinness des records]], avec plus de 17 millions d’exemplaires vendus dans le monde<ref>{{Lien web|langue=|auteur=Brandon Griggs|titre=The Commodore 64, that '80 s computer icon, lives again|url=https://web.archive.org/web/20200706161515/http://edition.cnn.com/2011/TECH/gaming.gadgets/05/09/commodore.64.reborn|site=CNN|date=May 9, 2011|consulté le=}}.</ref>. Juste avant cela, en 1981, l’''[[w:fr:IBM PC|IBM Personal Computer]]'' avait déjà fait son apparition, en proposant une [[w:Architecture_(informatique)|architecture]] ouverte qui allait servir de modèle pour toute une gamme d’ordinateurs que l’on désigne toujours aujourd’hui par l’acronyme « PC ». Pour faire fonctionner ses nouveaux modèles d'ordinateurs, la société IBM avait confié à l’entreprise [[w:Microsoft|Microsoft]], créée en 1975, la mission de les équiper d’un système d’exploitation. Le contrat signé entre les deux firmes fut une véritable aubaine pour le fournisseur des programmes informatiques. Car sans s'en apercevoir, et sans jamais anticiper que son matériel serait cloné à grande échelle, celle-ci avait en effet permis à Microsoft d'établir un monopole dans la vente de logiciels. Cela fut condamné pour [[w:Abus_de_position_dominante|abus de position dominante]]<ref name="Combier_2018_01_24">{{Lien web|langue=fr|auteur=Étienne Combier|titre=Abus de position dominante : les plus grosses amendes de la Commission européenne|url=https://web.archive.org/web/20230511110018/https://www.lesechos.fr/2018/01/abus-de-position-dominante-les-plus-grosses-amendes-de-la-commission-europeenne-982719|périodique=[[w:Les Échos|Les Échos]]|date=2018-01-24|consulté le=}}.</ref> et [[w:Vente_liée_de_logiciels_avec_du_matériel_informatique|vente liée du logiciel avec le matériel informatique]]<ref>{{Lien web|langue=fr|auteur=Marc Rees|titre=Pourquoi la justice européenne a sanctuarisé la vente liée PC et OS|url=https://web.archive.org/web/20230209112015/https://www.nextinpact.com/article/23625/101268-la-justice-europeenne-sanctuarise-vente-liee-pc-et-os|site=nextinpact.com|éditeur=[[w:Next INpact|Next INpact]]|date=2016-07-09|consulté le=}}.</ref>, mais sans pour autant empêcher [[w:Bill_Gates|Bill Gates]], le principal actionnaire de Microsoft, d'être l'homme le plus riche du monde en 1994. [[Fichier:GNU_and_Tux.svg|alt=Mascotte du projet GNU à gauche et du projet Linux à droite.|vignette|<small>Figure 5. À gauche la mascotte du projet GNU ; à droite celle du projet Linux, appelée Tux.</small>]] Toutefois, pendant que Microsoft renforçait sa position dominante, un nouvel événement majeur allait marquer l’histoire du logiciel libre. Celui-ci fut de nouveau déclenché par un appel à contribution, qui fut cette fois posté le vingt-cinq août 1991 par un jeune étudiant en informatique de 21 ans, appelé [[w:fr:Linus Torvalds|Linus Torvalds]]. Via le système de messagerie [[w:fr:Usenet|Usenet]], sa demande avait été posté dans une liste de diffusion consacrée au système d’exploitation [[w:fr:Minix|Minix]], une sorte d’[[w:UNIX|UNIX]] simplifié et développé dans un but didactique, par le programmeur [[w:fr:Andrew Tanenbaum|Andrew Tanenbaum]]. Loin d’imaginer que cela ferait de lui une nouvelle célébrité dans le monde du Libre<ref>{{Ouvrage|langue=|prénom1=Linus|nom1=Torvalds|prénom2=David|nom2=Diamond|prénom3=Olivier|nom3=Engler|titre=Il était une fois Linux|éditeur=Osman Eyrolles Multimédia|date=2001|isbn=978-2-7464-0321-5|oclc=48059105}}.</ref>, Torvalds entama son message par le paragraphe suivant<ref>{{Ouvrage|langue=|prénom1=Linus|nom1=Torvalds|prénom2=David|nom2=Diamond|titre=Just for fun : the story of an accidental revolutionary|éditeur=HarperBusiness|date=2002|isbn=978-0-06-662073-2|oclc=1049937833}}.</ref> : <blockquote> Je fais un système d’exploitation (gratuit) (juste un hobby, ne sera pas grand et professionnel comme gnu) pour les clones 386 (486) AT. Ce projet est en cours depuis avril et commence à se préparer. J’aimerais avoir un retour sur ce que les gens aiment ou n’aiment pas dans minix, car mon système d’exploitation lui ressemble un peu (même disposition physique du système de fichiers (pour des raisons pratiques) entre autres choses)<ref>Texte original avant sa traduction par www.deepl.com/translator : « ''I'm doing a (free) operating system (just a hobby, won't be big and professional like gnu) for 386(486) AT clones. This has been brewing since april, and is starting to get ready. I'd like any feedback on things people like/dislike in minix, as my OS resembles it somewhat (same physical layout of the file-system (due to practical reasons)among other things) ».''</ref>. </blockquote> Bien qu’il fût présenté comme un passe-temps, le projet qui répondait au nom de « [[w:fr:Noyau Linux|Linux]] », fut rapidement soutenu par des milliers de programmeurs du monde entier, avant de devenir la pièce manquante du projet GNU. En effet, les contributeurs au projet de Stallman n’avaient pas encore terminé l’écriture du code informatique du [[w:noyau_de_système_d'exploitation|noyau]] [[w:GNU Hurd|Hurd]], alors qu'il était censé établir la communication entre la [[w:Suite_logicielle|suite logicielle]] produite par GNU et le [[w:Matériel informatique|matériel informatique]]. C'est donc la fusion des codes produits par les projets GNU et Linux qui permit la création du premier système complet, stable et entièrement libre baptisé [[w:GNU/Linux|GNU/Linux]]. [[Fichier:Debian-OpenLogo.svg|gauche|vignette|<small>Figure 6. Logo du système d’exploitation Debian.</small>|264x264px]] Au départ de ce nouveau système informatique, de nombreuses variantes, que l’on nomme communément « [[w:Distribution_Linux|distributions]] », furent créées par des programmeurs de tous horizons. L’une de celles-ci s’intitule [[w:fr:Debian|Debian]] et tire sa réputation d'être simultanément libre, gratuite et produite par une communauté sans lien direct avec une société commerciale<ref>{{Ouvrage|langue=|auteur=|prénom1=Christophe|nom1=Lazaro|titre=La liberte logicielle|passage=|lieu=|éditeur=Academia Bruylant|collection=Anthropologie Prospective|date=2012|pages totales=56|isbn=978-2-87209-861-3|oclc=1104281978}}.</ref>. Cela n'a pas empêché pour autant que le code de ce système informatique soit récupéré par plus de 150 distributions dérivées. Quant à la fiabilité du système Debian, elle se confirme par son usage au sein de nombreuses entreprises et organisations, à l’image de la [[w:Wikimedia_Foundation|Fondation Wikimédia]] qui l’utilise sur [[m:Wikimedia_servers/fr|ses serveurs]] pour héberger les projets qu'elle supporte<ref>{{Lien web|langue=|auteur=Méta-Wiki|titre=Serveurs Wikimedia|url=https://web.archive.org/web/20251113214321/https://meta.wikimedia.org/wiki/Wikimedia_servers/fr|site=|date=|consulté le=}}.</ref>. Grâce à la naissance des logiciels libres, le mouvement Wikimédia a donc la possibilité de faire tourner ses serveurs informatiques, avec un système d’exploitation fiable, libre et gratuit. Comme son [[w:Code_source|code source⁣⁣]] est ouvert, cela permet aussi à la Fondation Wikimédia de le modifier pour répondre aux besoins spécifiques du mouvement. À la suite de quoi, et selon les règles formulées par la [[w:Communauté_du_logiciel_libre|communauté du logiciel libre]], les modifications faites par la Fondation deviennent à leur tour, gratuitement et librement, utilisables par d’autres personnes ou organismes. À ce premier héritage reçu par le mouvement Wikimédia s’ajoute ensuite une innovation méthodologique, toujours en provenance des logiciels libres. Dans son article ''[[w:La_Cathédrale_et_le_Bazar|La Cathédrale et le Bazar]]''<ref>{{Ouvrage|langue=|auteur=|prénom1=Eric Steven|nom1=Raymond|titre=Cathedral and the bazaar|titre original=Cathedral and the bazaar|traduction titre=La cathédrale et le bazar|passage=|lieu=|éditeur=SnowBall Publishing|date=2010|pages totales=|isbn=978-1-60796-228-1|oclc=833142152|lire en ligne=}}.</ref>, [[w:Eric_Raymond|Eric Raymond]] mobilise en effet le terme « [[w:Cathédrale|cathédrale]] » pour désigner le mode de production des logiciels propriétaires, en opposition au mot « [[w:fr:Bazar|bazar]] », qu'il utilise pour qualifier le mode de développement des logiciels libres. D’un côté, il décrit une organisation pyramidale, rigide et statutairement hiérarchisée, comme on peut la voir souvent au sein des entreprises. Tandis que de l’autre, il parle d’une organisation horizontale, flexible et peu hiérarchisée, qu’il a lui-même expérimentée en adoptant le style de développement de Linus Torvalds, à savoir : « distribuez vite et souvent, déléguez tout ce que vous pouvez déléguer, soyez ouvert jusqu’à la promiscuité »<ref>{{Lien web|langue=|auteur=Eric S. Raymond|traducteur=Sébastien Blondeel|titre=La cathédrale et le bazar|url=https://web.archive.org/web/20200203054716/http://www.linux-france.org/article/these/cathedrale-bazar/cathedrale-bazar-1.html|site=Linux France|lieu=|date=1998|consulté le=}}.</ref>. À l’instar de la métaphore du quartier numérique présentée dans le précédent chapitre, cette manière de décrire les projets open source nous aide donc ici à mieux comprendre ce qui se passe dans le mouvement Wikimédia. D'un côté, on retrouve effectivement cette « ouverture jusqu’à la promiscuité », dans le libre accès accordé aux projets Wikimédia, alors que de l'autre, tout le monde peut participer aux projets Wikimédia, qu'ils soient en ligne ou hors ligne. Ces deux observations corroborent donc l’existence d’un deuxième héritage, en provenance du mouvement du logiciel libre. Néanmoins, il nous reste encore à découvrir un phénomène négligé par Eric Raymond durant ses observations, et qui a pourtant une importance considérable dans l'histoire de la révolution numérique. Il s’agit là de l’apparition de la licence libre, de la philosophie qu'elle sous-tend, et de la [[w:Culture_libre|culture libre]] dont elle fut à l’origine. {{AutoCat}} l79j815enufw7vgeutlj5de7aon8c4j 765178 765177 2026-04-27T06:39:19Z Lionel Scheepmans 20012 765178 wikitext text/x-wiki <noinclude>{{Le mouvement Wikimédia}}</noinclude> L’un des premiers épisodes de la préhistoire de Wikipédia et du mouvement Wikimédia débuta en septembre 1983, lorsqu’un programmeur du ''[[w:fr:Massachusetts Institute of Technology|Massachusetts Institute of Technology]]'', appelé [[w:fr:Richard Stallman|Richard Stallman]], déposa un message sur la liste de diffusion net.unix-wizards. C’était un appel d’aide pour la création de [[w:Projet GNU|GNU]], un nouveau [[w:fr:Système d'exploitation|système d’exploitation]] qui devait réunir une suite de programmes que tout le monde pourrait utiliser librement sur son ordinateur personnel<ref>{{Ouvrage|langue=|prénom1=Richard M|nom1=Stallman|prénom2=Sam|nom2=Williams|titre=Richard Stallman et la révolution du logiciel libre - Une biographie autorisée|éditeur=Eyrolles|date=2013|oclc=708380925|lire en ligne=https://framabook.org/docs/stallman/framabook6_stallman_v1_gnu-fdl.pdf|consulté le=}}.</ref>. Dans son message transmis via [[w:Arpanet|ARPANET]], le premier réseau informatique à grande échelle qui précéda Internet, Stallman s’exprimait de la sorte<ref>{{Lien web|langue=|auteur=Richard Stallman|titre=Système d'exploitation GNU – Annonce initiale|url=https://web.archive.org/web/20010106133800/http://www.gnu.org:80/gnu/initial-announcement.fr.html|site=GNU|date=3 décembre 2000|consulté le=}}.</ref> : <blockquote> Je considère comme une [[w:Règle d'or|règle d’or]] que si j’apprécie un programme je dois le partager avec d’autres personnes qui l’apprécient. Je ne peux pas en bonne conscience signer un accord de non-divulgation ni un accord de licence de logiciel. Afin de pouvoir continuer à utiliser les ordinateurs sans violer mes principes, j’ai décidé de rassembler une quantité suffisante de logiciels libres, de manière à pouvoir m’en tirer sans aucun logiciel qui ne soit pas libre. </blockquote> Le projet de Stallman, qui reçut le soutien nécessaire à son accomplissement, marqua ainsi le début de l’[[w:Histoire_du_logiciel_libre|histoire du logiciel libre]]. Quant à la quantité d’aide fournie, elle permet de croire que Richard Stallman n’était pas seul à voir l’arrivée des [[w:Logiciel propriétaire|logiciels propriétaires]] d’un mauvais œil. Car pour les membres du projet GNU et du mouvement du logiciel libre en général, un bon programme informatique doit respecter ces quatre libertés fondamentales<ref>{{Lien web|langue=|auteur=Karl Pradène|titre=Qu'est-ce que le logiciel libre ?|url=https://web.archive.org/web/20000511101640/http://www.gnu.org/philosophy/free-sw.fr.html|site=GNU|date=6 mai 2000|consulté le=}}.</ref> : <blockquote> 1. La liberté d’exécuter le programme, pour tous les usages. 2. La liberté d’étudier le fonctionnement du programme, et de l’adapter à vos besoins. 3. La liberté de redistribuer des copies, donc d’aider votre voisin. 4. La liberté d’améliorer le programme, et de publier vos améliorations, pour en faire profiter toute la communauté. </blockquote> [[w:Histoire_du_logiciel_libre|Lors de l'apparition du logiciel libre]], le marché de l’informatique était de fait en pleine mutation. L'habituel partage des codes informatiques entre les rares étudiants ou chercheurs qui bénéficiaient d’un accès à un ordinateur faisait l'objet d'une remise en question. Ce changement faisait notamment suite au [[w:Copyright_Act_(1976)|Copyright Act]] de 1976, une nouvelle loi qui autorisait l'application d'un [[w:Droit_d'auteur|droit d'auteur]] sur le code informatique, et donc qui permettait d'en interdire le partage ou la réutilisation sans autorisation. Des [[w:Clause_de_confidentialité|clauses de confidentialité]] ont ainsi fait leur apparition, pendant que les employés des firmes informatiques étaient nouvellement soumis à des contrats de confidentialité. C'était la fin de l’entraide et de la solidarité pratiquées chez les pionniers de l’informatique. À sa place s'installaient la concurrence et la compétitivité, bien connues dans le système capitaliste marchand. [[Fichier:Commodore64withdisk.jpg|alt=Commodore 64 avec disquette et lecteur|gauche|vignette|<small>Figure 4. Commodore 64 avec disquette et lecteur.</small>|300x300px]] Cette mutation coïncidait avec l’arrivée des premiers ordinateurs de taille réduite. Grâce à l’apparition des premiers [[w:Circuits_intégrés|circuits intégrés⁣⁣]], les premiers exemplaires avaient en effet été créés par l’industrie aérospatiale au début des années 1960. Cependant, il fallut attendre le début des années 1980 pour que le prix d’un ordinateur soit suffisamment bas pour en faire un [[w:Bien_de_grande_consommation|bien de grande consommation]]. C’est ainsi qu’en 1982, le [[w:Commodore 64|Commodore 64]] entrait dans le [[w:Livre_Guinness_des_records|livre Guinness des records]], avec plus de 17 millions d’exemplaires vendus dans le monde<ref>{{Lien web|langue=|auteur=Brandon Griggs|titre=The Commodore 64, that '80 s computer icon, lives again|url=https://web.archive.org/web/20200706161515/http://edition.cnn.com/2011/TECH/gaming.gadgets/05/09/commodore.64.reborn|site=CNN|date=May 9, 2011|consulté le=}}.</ref>. Juste avant cela, en 1981, l’''[[w:fr:IBM PC|IBM Personal Computer]]'' avait déjà fait son apparition, en proposant une [[w:Architecture_(informatique)|architecture]] ouverte qui allait servir de modèle pour toute une gamme d’ordinateurs que l’on désigne toujours aujourd’hui par l’acronyme « PC ». Pour faire fonctionner ses nouveaux modèles d'ordinateurs, la société IBM avait confié à l’entreprise [[w:Microsoft|Microsoft]], créée en 1975, la mission de les équiper d’un système d’exploitation. Le contrat signé entre les deux firmes fut une véritable aubaine pour le fournisseur des programmes informatiques. Car sans s'en apercevoir, et sans jamais anticiper que son matériel serait cloné à grande échelle, celle-ci avait en effet permis à Microsoft d'établir un monopole dans la vente de logiciels. Cela fut condamné pour [[w:Abus_de_position_dominante|abus de position dominante]]<ref name="Combier_2018_01_24">{{Lien web|langue=fr|auteur=Étienne Combier|titre=Abus de position dominante : les plus grosses amendes de la Commission européenne|url=https://web.archive.org/web/20230511110018/https://www.lesechos.fr/2018/01/abus-de-position-dominante-les-plus-grosses-amendes-de-la-commission-europeenne-982719|périodique=[[w:Les Échos|Les Échos]]|date=2018-01-24|consulté le=}}.</ref> et [[w:Vente_liée_de_logiciels_avec_du_matériel_informatique|vente liée du logiciel avec le matériel informatique]]<ref>{{Lien web|langue=fr|auteur=Marc Rees|titre=Pourquoi la justice européenne a sanctuarisé la vente liée PC et OS|url=https://web.archive.org/web/20230209112015/https://www.nextinpact.com/article/23625/101268-la-justice-europeenne-sanctuarise-vente-liee-pc-et-os|site=nextinpact.com|éditeur=[[w:Next INpact|Next INpact]]|date=2016-07-09|consulté le=}}.</ref>, mais sans pour autant empêcher [[w:Bill_Gates|Bill Gates]], le principal actionnaire de Microsoft, d'être l'homme le plus riche du monde en 1994. [[Fichier:GNU_and_Tux.svg|alt=Mascotte du projet GNU à gauche et du projet Linux à droite.|vignette|<small>Figure 5. À gauche la mascotte du projet GNU ; à droite celle du projet Linux, appelée Tux.</small>]] Toutefois, pendant que Microsoft renforçait sa position dominante, un nouvel événement majeur allait marquer l’histoire du logiciel libre. Celui-ci fut de nouveau déclenché par un appel à contribution, qui fut cette fois posté le vingt-cinq août 1991 par un jeune étudiant en informatique de 21 ans, appelé [[w:fr:Linus Torvalds|Linus Torvalds]]. Via le système de messagerie [[w:fr:Usenet|Usenet]], sa demande avait été posté dans une liste de diffusion consacrée au système d’exploitation [[w:fr:Minix|Minix]], une sorte d’[[w:UNIX|UNIX]] simplifié et développé dans un but didactique, par le programmeur [[w:fr:Andrew Tanenbaum|Andrew Tanenbaum]]. Loin d’imaginer que cela ferait de lui une nouvelle célébrité dans le monde du Libre<ref>{{Ouvrage|langue=|prénom1=Linus|nom1=Torvalds|prénom2=David|nom2=Diamond|prénom3=Olivier|nom3=Engler|titre=Il était une fois Linux|éditeur=Osman Eyrolles Multimédia|date=2001|isbn=978-2-7464-0321-5|oclc=48059105}}.</ref>, Torvalds entama son message par le paragraphe suivant<ref>{{Ouvrage|langue=|prénom1=Linus|nom1=Torvalds|prénom2=David|nom2=Diamond|titre=Just for fun : the story of an accidental revolutionary|éditeur=HarperBusiness|date=2002|isbn=978-0-06-662073-2|oclc=1049937833}}.</ref> : <blockquote> Je fais un système d’exploitation (gratuit) (juste un hobby, ne sera pas grand et professionnel comme gnu) pour les clones 386 (486) AT. Ce projet est en cours depuis avril et commence à se préparer. J’aimerais avoir un retour sur ce que les gens aiment ou n’aiment pas dans minix, car mon système d’exploitation lui ressemble un peu (même disposition physique du système de fichiers (pour des raisons pratiques) entre autres choses)<ref>Texte original avant sa traduction par www.deepl.com/translator : « ''I'm doing a (free) operating system (just a hobby, won't be big and professional like gnu) for 386(486) AT clones. This has been brewing since april, and is starting to get ready. I'd like any feedback on things people like/dislike in minix, as my OS resembles it somewhat (same physical layout of the file-system (due to practical reasons)among other things) ».''</ref>. </blockquote> Bien qu’il fût présenté comme un passe-temps, le projet qui répondait au nom de « [[w:fr:Noyau Linux|Linux]] », fut rapidement soutenu par des milliers de programmeurs du monde entier, avant de devenir la pièce manquante du projet GNU. En effet, les contributeurs au projet de Stallman n’avaient pas encore terminé l’écriture du code informatique du [[w:noyau_de_système_d'exploitation|noyau]] [[w:GNU Hurd|Hurd]], alors qu'il était censé établir la communication entre la [[w:Suite_logicielle|suite logicielle]] produite par GNU et le [[w:Matériel informatique|matériel informatique]]. C'est donc la fusion des codes produits par les projets GNU et Linux qui permit la création du premier système complet, stable et entièrement libre baptisé [[w:GNU/Linux|GNU/Linux]]. [[Fichier:Debian-OpenLogo.svg|gauche|vignette|<small>Figure 6. Logo du système d’exploitation Debian.</small>|264x264px]] Au départ de ce nouveau système informatique, de nombreuses variantes, que l’on nomme communément « [[w:Distribution_Linux|distributions]] », furent créées par des programmeurs de tous horizons. L’une de celles-ci s’intitule [[w:fr:Debian|Debian]] et tire sa réputation d'être simultanément libre, gratuite, très fiable et produite par une communauté sans lien direct avec une société commerciale<ref>{{Ouvrage|langue=|auteur=|prénom1=Christophe|nom1=Lazaro|titre=La liberte logicielle|passage=|lieu=|éditeur=Academia Bruylant|collection=Anthropologie Prospective|date=2012|pages totales=56|isbn=978-2-87209-861-3|oclc=1104281978}}.</ref>. Quatre qualités qui explique sans doute pourquoi son code source est utilisé plus de 150 distributions dérivées, et que son usage dann de nombreuses entreprises et organisations, à l’image de la [[w:Wikimedia_Foundation|Fondation Wikimédia]] qui l’utilise sur [[m:Wikimedia_servers/fr|ses serveurs]] pour héberger les projets qu'elle supporte<ref>{{Lien web|langue=|auteur=Méta-Wiki|titre=Serveurs Wikimedia|url=https://web.archive.org/web/20251113214321/https://meta.wikimedia.org/wiki/Wikimedia_servers/fr|site=|date=|consulté le=}}.</ref>. Grâce à la naissance des logiciels libres, le mouvement Wikimédia a donc la possibilité de faire tourner ses serveurs informatiques, avec un système d’exploitation fiable, libre et gratuit. Comme son [[w:Code_source|code source⁣⁣]] est ouvert, cela permet aussi à la Fondation Wikimédia de le modifier pour répondre aux besoins spécifiques du mouvement. À la suite de quoi, et selon les règles formulées par la [[w:Communauté_du_logiciel_libre|communauté du logiciel libre]], les modifications faites par la Fondation deviennent à leur tour, gratuitement et librement, utilisables par d’autres personnes ou organismes. À ce premier héritage reçu par le mouvement Wikimédia s’ajoute ensuite une innovation méthodologique, toujours en provenance des logiciels libres. Dans son article ''[[w:La_Cathédrale_et_le_Bazar|La Cathédrale et le Bazar]]''<ref>{{Ouvrage|langue=|auteur=|prénom1=Eric Steven|nom1=Raymond|titre=Cathedral and the bazaar|titre original=Cathedral and the bazaar|traduction titre=La cathédrale et le bazar|passage=|lieu=|éditeur=SnowBall Publishing|date=2010|pages totales=|isbn=978-1-60796-228-1|oclc=833142152|lire en ligne=}}.</ref>, [[w:Eric_Raymond|Eric Raymond]] mobilise en effet le terme « [[w:Cathédrale|cathédrale]] » pour désigner le mode de production des logiciels propriétaires, en opposition au mot « [[w:fr:Bazar|bazar]] », qu'il utilise pour qualifier le mode de développement des logiciels libres. D’un côté, il décrit une organisation pyramidale, rigide et statutairement hiérarchisée, comme on peut la voir souvent au sein des entreprises. Tandis que de l’autre, il parle d’une organisation horizontale, flexible et peu hiérarchisée, qu’il a lui-même expérimentée en adoptant le style de développement de Linus Torvalds, à savoir : « distribuez vite et souvent, déléguez tout ce que vous pouvez déléguer, soyez ouvert jusqu’à la promiscuité »<ref>{{Lien web|langue=|auteur=Eric S. Raymond|traducteur=Sébastien Blondeel|titre=La cathédrale et le bazar|url=https://web.archive.org/web/20200203054716/http://www.linux-france.org/article/these/cathedrale-bazar/cathedrale-bazar-1.html|site=Linux France|lieu=|date=1998|consulté le=}}.</ref>. À l’instar de la métaphore du quartier numérique présentée dans le précédent chapitre, cette manière de décrire les projets open source nous aide donc ici à mieux comprendre ce qui se passe dans le mouvement Wikimédia. D'un côté, on retrouve effectivement cette « ouverture jusqu’à la promiscuité », dans le libre accès accordé aux projets Wikimédia, alors que de l'autre, tout le monde peut participer aux projets Wikimédia, qu'ils soient en ligne ou hors ligne. Ces deux observations corroborent donc l’existence d’un deuxième héritage, en provenance du mouvement du logiciel libre. Néanmoins, il nous reste encore à découvrir un phénomène négligé par Eric Raymond durant ses observations, et qui a pourtant une importance considérable dans l'histoire de la révolution numérique. Il s’agit là de l’apparition de la licence libre, de la philosophie qu'elle sous-tend, et de la [[w:Culture_libre|culture libre]] dont elle fut à l’origine. {{AutoCat}} ixpvqnfxp9kngjwy0jgg93gtyr0s7uw 765179 765178 2026-04-27T06:46:23Z Lionel Scheepmans 20012 765179 wikitext text/x-wiki <noinclude>{{Le mouvement Wikimédia}}</noinclude> L’un des premiers épisodes de la préhistoire de Wikipédia et du mouvement Wikimédia débuta en septembre 1983, lorsqu’un programmeur du ''[[w:fr:Massachusetts Institute of Technology|Massachusetts Institute of Technology]]'', appelé [[w:fr:Richard Stallman|Richard Stallman]], déposa un message sur la liste de diffusion net.unix-wizards. C’était un appel d’aide pour la création de [[w:Projet GNU|GNU]], un nouveau [[w:fr:Système d'exploitation|système d’exploitation]] qui devait réunir une suite de programmes que tout le monde pourrait utiliser librement sur son ordinateur personnel<ref>{{Ouvrage|langue=|prénom1=Richard M|nom1=Stallman|prénom2=Sam|nom2=Williams|titre=Richard Stallman et la révolution du logiciel libre - Une biographie autorisée|éditeur=Eyrolles|date=2013|oclc=708380925|lire en ligne=https://framabook.org/docs/stallman/framabook6_stallman_v1_gnu-fdl.pdf|consulté le=}}.</ref>. Dans son message transmis via [[w:Arpanet|ARPANET]], le premier réseau informatique à grande échelle qui précéda Internet, Stallman s’exprimait de la sorte<ref>{{Lien web|langue=|auteur=Richard Stallman|titre=Système d'exploitation GNU – Annonce initiale|url=https://web.archive.org/web/20010106133800/http://www.gnu.org:80/gnu/initial-announcement.fr.html|site=GNU|date=3 décembre 2000|consulté le=}}.</ref> : <blockquote> Je considère comme une [[w:Règle d'or|règle d’or]] que si j’apprécie un programme je dois le partager avec d’autres personnes qui l’apprécient. Je ne peux pas en bonne conscience signer un accord de non-divulgation ni un accord de licence de logiciel. Afin de pouvoir continuer à utiliser les ordinateurs sans violer mes principes, j’ai décidé de rassembler une quantité suffisante de logiciels libres, de manière à pouvoir m’en tirer sans aucun logiciel qui ne soit pas libre. </blockquote> Le projet de Stallman, qui reçut le soutien nécessaire à son accomplissement, marqua ainsi le début de l’[[w:Histoire_du_logiciel_libre|histoire du logiciel libre]]. Quant à la quantité d’aide fournie, elle permet de croire que Richard Stallman n’était pas seul à voir l’arrivée des [[w:Logiciel propriétaire|logiciels propriétaires]] d’un mauvais œil. Car pour les membres du projet GNU et du mouvement du logiciel libre en général, un bon programme informatique doit respecter ces quatre libertés fondamentales<ref>{{Lien web|langue=|auteur=Karl Pradène|titre=Qu'est-ce que le logiciel libre ?|url=https://web.archive.org/web/20000511101640/http://www.gnu.org/philosophy/free-sw.fr.html|site=GNU|date=6 mai 2000|consulté le=}}.</ref> : <blockquote> 1. La liberté d’exécuter le programme, pour tous les usages. 2. La liberté d’étudier le fonctionnement du programme, et de l’adapter à vos besoins. 3. La liberté de redistribuer des copies, donc d’aider votre voisin. 4. La liberté d’améliorer le programme, et de publier vos améliorations, pour en faire profiter toute la communauté. </blockquote> [[w:Histoire_du_logiciel_libre|Lors de l'apparition du logiciel libre]], le marché de l’informatique était de fait en pleine mutation. L'habituel partage des codes informatiques entre les rares étudiants ou chercheurs qui bénéficiaient d’un accès à un ordinateur faisait l'objet d'une remise en question. Ce changement faisait notamment suite au [[w:Copyright_Act_(1976)|Copyright Act]] de 1976, une nouvelle loi qui autorisait l'application d'un [[w:Droit_d'auteur|droit d'auteur]] sur le code informatique, et donc qui permettait d'en interdire le partage ou la réutilisation sans autorisation. Des [[w:Clause_de_confidentialité|clauses de confidentialité]] ont ainsi fait leur apparition, pendant que les employés des firmes informatiques étaient nouvellement soumis à des contrats de confidentialité. C'était la fin de l’entraide et de la solidarité pratiquées chez les pionniers de l’informatique. À sa place s'installaient la concurrence et la compétitivité, bien connues dans le système capitaliste marchand. [[Fichier:Commodore64withdisk.jpg|alt=Commodore 64 avec disquette et lecteur|gauche|vignette|<small>Figure 4. Commodore 64 avec disquette et lecteur.</small>|300x300px]] Cette mutation coïncidait avec l’arrivée des premiers ordinateurs de taille réduite. Grâce à l’apparition des premiers [[w:Circuits_intégrés|circuits intégrés⁣⁣]], les premiers exemplaires avaient en effet été créés par l’industrie aérospatiale au début des années 1960. Cependant, il fallut attendre le début des années 1980 pour que le prix d’un ordinateur soit suffisamment bas pour en faire un [[w:Bien_de_grande_consommation|bien de grande consommation]]. C’est ainsi qu’en 1982, le [[w:Commodore 64|Commodore 64]] entrait dans le [[w:Livre_Guinness_des_records|livre Guinness des records]], avec plus de 17 millions d’exemplaires vendus dans le monde<ref>{{Lien web|langue=|auteur=Brandon Griggs|titre=The Commodore 64, that '80 s computer icon, lives again|url=https://web.archive.org/web/20200706161515/http://edition.cnn.com/2011/TECH/gaming.gadgets/05/09/commodore.64.reborn|site=CNN|date=May 9, 2011|consulté le=}}.</ref>. Juste avant cela, en 1981, l’''[[w:fr:IBM PC|IBM Personal Computer]]'' avait déjà fait son apparition, en proposant une [[w:Architecture_(informatique)|architecture]] ouverte qui allait servir de modèle pour toute une gamme d’ordinateurs que l’on désigne toujours aujourd’hui par l’acronyme « PC ». Pour faire fonctionner ses nouveaux modèles d'ordinateurs, la société IBM avait confié à l’entreprise [[w:Microsoft|Microsoft]], créée en 1975, la mission de les équiper d’un système d’exploitation. Le contrat signé entre les deux firmes fut une véritable aubaine pour le fournisseur des programmes informatiques. Car sans s'en apercevoir, et sans jamais anticiper que son matériel serait cloné à grande échelle, celle-ci avait en effet permis à Microsoft d'établir un monopole dans la vente de logiciels. Cela fut condamné pour [[w:Abus_de_position_dominante|abus de position dominante]]<ref name="Combier_2018_01_24">{{Lien web|langue=fr|auteur=Étienne Combier|titre=Abus de position dominante : les plus grosses amendes de la Commission européenne|url=https://web.archive.org/web/20230511110018/https://www.lesechos.fr/2018/01/abus-de-position-dominante-les-plus-grosses-amendes-de-la-commission-europeenne-982719|périodique=[[w:Les Échos|Les Échos]]|date=2018-01-24|consulté le=}}.</ref> et [[w:Vente_liée_de_logiciels_avec_du_matériel_informatique|vente liée du logiciel avec le matériel informatique]]<ref>{{Lien web|langue=fr|auteur=Marc Rees|titre=Pourquoi la justice européenne a sanctuarisé la vente liée PC et OS|url=https://web.archive.org/web/20230209112015/https://www.nextinpact.com/article/23625/101268-la-justice-europeenne-sanctuarise-vente-liee-pc-et-os|site=nextinpact.com|éditeur=[[w:Next INpact|Next INpact]]|date=2016-07-09|consulté le=}}.</ref>, mais sans pour autant empêcher [[w:Bill_Gates|Bill Gates]], le principal actionnaire de Microsoft, d'être l'homme le plus riche du monde en 1994. [[Fichier:GNU_and_Tux.svg|alt=Mascotte du projet GNU à gauche et du projet Linux à droite.|vignette|<small>Figure 5. À gauche la mascotte du projet GNU ; à droite celle du projet Linux, appelée Tux.</small>]] Toutefois, pendant que Microsoft renforçait sa position dominante, un nouvel événement majeur allait marquer l’histoire du logiciel libre. Celui-ci fut de nouveau déclenché par un appel à contribution, qui fut cette fois posté le vingt-cinq août 1991 par un jeune étudiant en informatique de 21 ans, appelé [[w:fr:Linus Torvalds|Linus Torvalds]]. Via le système de messagerie [[w:fr:Usenet|Usenet]], sa demande avait été posté dans une liste de diffusion consacrée au système d’exploitation [[w:fr:Minix|Minix]], une sorte d’[[w:UNIX|UNIX]] simplifié et développé dans un but didactique, par le programmeur [[w:fr:Andrew Tanenbaum|Andrew Tanenbaum]]. Loin d’imaginer que cela ferait de lui une nouvelle célébrité dans le monde du Libre<ref>{{Ouvrage|langue=|prénom1=Linus|nom1=Torvalds|prénom2=David|nom2=Diamond|prénom3=Olivier|nom3=Engler|titre=Il était une fois Linux|éditeur=Osman Eyrolles Multimédia|date=2001|isbn=978-2-7464-0321-5|oclc=48059105}}.</ref>, Torvalds entama son message par le paragraphe suivant<ref>{{Ouvrage|langue=|prénom1=Linus|nom1=Torvalds|prénom2=David|nom2=Diamond|titre=Just for fun : the story of an accidental revolutionary|éditeur=HarperBusiness|date=2002|isbn=978-0-06-662073-2|oclc=1049937833}}.</ref> : <blockquote> Je fais un système d’exploitation (gratuit) (juste un hobby, ne sera pas grand et professionnel comme gnu) pour les clones 386 (486) AT. Ce projet est en cours depuis avril et commence à se préparer. J’aimerais avoir un retour sur ce que les gens aiment ou n’aiment pas dans minix, car mon système d’exploitation lui ressemble un peu (même disposition physique du système de fichiers (pour des raisons pratiques) entre autres choses)<ref>Texte original avant sa traduction par www.deepl.com/translator : « ''I'm doing a (free) operating system (just a hobby, won't be big and professional like gnu) for 386(486) AT clones. This has been brewing since april, and is starting to get ready. I'd like any feedback on things people like/dislike in minix, as my OS resembles it somewhat (same physical layout of the file-system (due to practical reasons)among other things) ».''</ref>. </blockquote> Bien qu’il fût présenté comme un passe-temps, le projet qui répondait au nom de « [[w:fr:Noyau Linux|Linux]] », fut rapidement soutenu par des milliers de programmeurs du monde entier, avant de devenir la pièce manquante du projet GNU. En effet, les contributeurs au projet de Stallman n’avaient pas encore terminé l’écriture du code informatique du [[w:noyau_de_système_d'exploitation|noyau]] [[w:GNU Hurd|Hurd]], alors qu'il était censé établir la communication entre la [[w:Suite_logicielle|suite logicielle]] produite par GNU et le [[w:Matériel informatique|matériel informatique]]. C'est donc la fusion des codes produits par les projets GNU et Linux qui permit la création du premier système complet, stable et entièrement libre baptisé [[w:GNU/Linux|GNU/Linux]]. [[Fichier:Debian-OpenLogo.svg|gauche|vignette|<small>Figure 6. Logo du système d’exploitation Debian.</small>|264x264px]] Au départ de ce nouveau système informatique, de nombreuses variantes, que l’on nomme communément « [[w:Distribution_Linux|distributions]] », furent créées par des programmeurs de tous horizons. L’une de celles-ci s’intitule [[w:fr:Debian|Debian]] et tire sa réputation d'être simultanément libre, gratuite, très fiable et produite par une communauté sans lien direct avec une société commerciale<ref>{{Ouvrage|langue=|auteur=|prénom1=Christophe|nom1=Lazaro|titre=La liberte logicielle|passage=|lieu=|éditeur=Academia Bruylant|collection=Anthropologie Prospective|date=2012|pages totales=56|isbn=978-2-87209-861-3|oclc=1104281978}}.</ref>. Quatre qualités qui expliquent pourquoi, Debian est utilisé dans plus de 150 distributions dérivées, mais aussi par de nombreuses entreprises et organisations, à l’image de la [[w:Wikimedia_Foundation|Fondation Wikimédia]], qui l’utilise sur [[metawiki:Wikimedia_servers/fr|ses serveurs]], pour héberger les projets qu'elle supporte<ref>{{Lien web|langue=|auteur=Méta-Wiki|titre=Serveurs Wikimedia|url=https://web.archive.org/web/20251113214321/https://meta.wikimedia.org/wiki/Wikimedia_servers/fr|site=|date=|consulté le=}}.</ref>. Grâce à la naissance des logiciels libres, le mouvement Wikimédia a donc la possibilité de faire tourner ses serveurs informatiques, avec un système d’exploitation fiable, libre et gratuit. Comme son [[w:Code_source|code source⁣⁣]] est ouvert, cela permet aussi à la Fondation Wikimédia de le modifier pour répondre aux besoins spécifiques du mouvement. À la suite de quoi, et selon les règles formulées par la [[w:Communauté_du_logiciel_libre|communauté du logiciel libre]], les modifications faites par la Fondation deviennent à leur tour, gratuitement et librement, utilisables par d’autres personnes ou organismes. À ce premier héritage reçu par le mouvement Wikimédia s’ajoute ensuite une innovation méthodologique, toujours en provenance des logiciels libres. Dans son article ''[[w:La_Cathédrale_et_le_Bazar|La Cathédrale et le Bazar]]''<ref>{{Ouvrage|langue=|auteur=|prénom1=Eric Steven|nom1=Raymond|titre=Cathedral and the bazaar|titre original=Cathedral and the bazaar|traduction titre=La cathédrale et le bazar|passage=|lieu=|éditeur=SnowBall Publishing|date=2010|pages totales=|isbn=978-1-60796-228-1|oclc=833142152|lire en ligne=}}.</ref>, [[w:Eric_Raymond|Eric Raymond]] mobilise en effet le terme « [[w:Cathédrale|cathédrale]] » pour désigner le mode de production des logiciels propriétaires, en opposition au mot « [[w:fr:Bazar|bazar]] », qu'il utilise pour qualifier le mode de développement des logiciels libres. D’un côté, il décrit une organisation pyramidale, rigide et statutairement hiérarchisée, comme on peut la voir souvent au sein des entreprises. Tandis que de l’autre, il parle d’une organisation horizontale, flexible et peu hiérarchisée, qu’il a lui-même expérimentée en adoptant le style de développement de Linus Torvalds, à savoir : « distribuez vite et souvent, déléguez tout ce que vous pouvez déléguer, soyez ouvert jusqu’à la promiscuité »<ref>{{Lien web|langue=|auteur=Eric S. Raymond|traducteur=Sébastien Blondeel|titre=La cathédrale et le bazar|url=https://web.archive.org/web/20200203054716/http://www.linux-france.org/article/these/cathedrale-bazar/cathedrale-bazar-1.html|site=Linux France|lieu=|date=1998|consulté le=}}.</ref>. À l’instar de la métaphore du quartier numérique présentée dans le précédent chapitre, cette manière de décrire les projets open source nous aide donc ici à mieux comprendre ce qui se passe dans le mouvement Wikimédia. D'un côté, on retrouve effectivement cette « ouverture jusqu’à la promiscuité », dans le libre accès accordé aux projets Wikimédia, alors que de l'autre, tout le monde peut participer aux projets Wikimédia, qu'ils soient en ligne ou hors ligne. Ces deux observations corroborent donc l’existence d’un deuxième héritage, en provenance du mouvement du logiciel libre. Néanmoins, il nous reste encore à découvrir un phénomène négligé par Eric Raymond durant ses observations, et qui a pourtant une importance considérable dans l'histoire de la révolution numérique. Il s’agit là de l’apparition de la licence libre, de la philosophie qu'elle sous-tend, et de la [[w:Culture_libre|culture libre]] dont elle fut à l’origine. {{AutoCat}} irue4g702wsv3yliv6pnziv5pkkwreo 765180 765179 2026-04-27T06:51:39Z Lionel Scheepmans 20012 765180 wikitext text/x-wiki <noinclude>{{Le mouvement Wikimédia}}</noinclude> L’un des premiers épisodes de la préhistoire de Wikipédia et du mouvement Wikimédia débuta en septembre 1983, lorsqu’un programmeur du ''[[w:fr:Massachusetts Institute of Technology|Massachusetts Institute of Technology]]'', appelé [[w:fr:Richard Stallman|Richard Stallman]], déposa un message sur la liste de diffusion net.unix-wizards. C’était un appel d’aide pour la création de [[w:Projet GNU|GNU]], un nouveau [[w:fr:Système d'exploitation|système d’exploitation]] qui devait réunir une suite de programmes que tout le monde pourrait utiliser librement sur son ordinateur personnel<ref>{{Ouvrage|langue=|prénom1=Richard M|nom1=Stallman|prénom2=Sam|nom2=Williams|titre=Richard Stallman et la révolution du logiciel libre - Une biographie autorisée|éditeur=Eyrolles|date=2013|oclc=708380925|lire en ligne=https://framabook.org/docs/stallman/framabook6_stallman_v1_gnu-fdl.pdf|consulté le=}}.</ref>. Dans son message transmis via [[w:Arpanet|ARPANET]], le premier réseau informatique à grande échelle qui précéda Internet, Stallman s’exprimait de la sorte<ref>{{Lien web|langue=|auteur=Richard Stallman|titre=Système d'exploitation GNU – Annonce initiale|url=https://web.archive.org/web/20010106133800/http://www.gnu.org:80/gnu/initial-announcement.fr.html|site=GNU|date=3 décembre 2000|consulté le=}}.</ref> : <blockquote> Je considère comme une [[w:Règle d'or|règle d’or]] que si j’apprécie un programme je dois le partager avec d’autres personnes qui l’apprécient. Je ne peux pas en bonne conscience signer un accord de non-divulgation ni un accord de licence de logiciel. Afin de pouvoir continuer à utiliser les ordinateurs sans violer mes principes, j’ai décidé de rassembler une quantité suffisante de logiciels libres, de manière à pouvoir m’en tirer sans aucun logiciel qui ne soit pas libre. </blockquote> Le projet de Stallman, qui reçut le soutien nécessaire à son accomplissement, marqua ainsi le début de l’[[w:Histoire_du_logiciel_libre|histoire du logiciel libre]]. Quant à la quantité d’aide fournie, elle permet de croire que Richard Stallman n’était pas seul à voir l’arrivée des [[w:Logiciel propriétaire|logiciels propriétaires]] d’un mauvais œil. Car pour les membres du projet GNU et du mouvement du logiciel libre en général, un bon programme informatique doit respecter ces quatre libertés fondamentales<ref>{{Lien web|langue=|auteur=Karl Pradène|titre=Qu'est-ce que le logiciel libre ?|url=https://web.archive.org/web/20000511101640/http://www.gnu.org/philosophy/free-sw.fr.html|site=GNU|date=6 mai 2000|consulté le=}}.</ref> : <blockquote> 1. La liberté d’exécuter le programme, pour tous les usages. 2. La liberté d’étudier le fonctionnement du programme, et de l’adapter à vos besoins. 3. La liberté de redistribuer des copies, donc d’aider votre voisin. 4. La liberté d’améliorer le programme, et de publier vos améliorations, pour en faire profiter toute la communauté. </blockquote> [[w:Histoire_du_logiciel_libre|Lors de l'apparition du logiciel libre]], le marché de l’informatique était de fait en pleine mutation. L'habituel partage des codes informatiques entre les rares étudiants ou chercheurs qui bénéficiaient d’un accès à un ordinateur faisait l'objet d'une remise en question. Ce changement faisait notamment suite au [[w:Copyright_Act_(1976)|Copyright Act]] de 1976, une nouvelle loi qui autorisait l'application d'un [[w:Droit_d'auteur|droit d'auteur]] sur le code informatique, et donc qui permettait d'en interdire le partage ou la réutilisation sans autorisation. Des [[w:Clause_de_confidentialité|clauses de confidentialité]] ont ainsi fait leur apparition, pendant que les employés des firmes informatiques étaient nouvellement soumis à des contrats de confidentialité. C'était la fin de l’entraide et de la solidarité pratiquées chez les pionniers de l’informatique. À sa place s'installaient la concurrence et la compétitivité, bien connues dans le système capitaliste marchand. [[Fichier:Commodore64withdisk.jpg|alt=Commodore 64 avec disquette et lecteur|gauche|vignette|<small>Figure 4. Commodore 64 avec disquette et lecteur.</small>|300x300px]] Cette mutation coïncidait avec l’arrivée des premiers ordinateurs de taille réduite. Grâce à l’apparition des premiers [[w:Circuits_intégrés|circuits intégrés⁣⁣]], les premiers exemplaires avaient en effet été créés par l’industrie aérospatiale au début des années 1960. Cependant, il fallut attendre le début des années 1980 pour que le prix d’un ordinateur soit suffisamment bas pour en faire un [[w:Bien_de_grande_consommation|bien de grande consommation]]. C’est ainsi qu’en 1982, le [[w:Commodore 64|Commodore 64]] entrait dans le [[w:Livre_Guinness_des_records|livre Guinness des records]], avec plus de 17 millions d’exemplaires vendus dans le monde<ref>{{Lien web|langue=|auteur=Brandon Griggs|titre=The Commodore 64, that '80 s computer icon, lives again|url=https://web.archive.org/web/20200706161515/http://edition.cnn.com/2011/TECH/gaming.gadgets/05/09/commodore.64.reborn|site=CNN|date=May 9, 2011|consulté le=}}.</ref>. Juste avant cela, en 1981, l’''[[w:fr:IBM PC|IBM Personal Computer]]'' avait déjà fait son apparition, en proposant une [[w:Architecture_(informatique)|architecture]] ouverte qui allait servir de modèle pour toute une gamme d’ordinateurs que l’on désigne toujours aujourd’hui par l’acronyme « PC ». Pour faire fonctionner ses nouveaux modèles d'ordinateurs, la société IBM avait confié à l’entreprise [[w:Microsoft|Microsoft]], créée en 1975, la mission de les équiper d’un système d’exploitation. Le contrat signé entre les deux firmes fut une véritable aubaine pour le fournisseur des programmes informatiques. Car sans s'en apercevoir, et sans jamais anticiper que son matériel serait cloné à grande échelle, celle-ci avait en effet permis à Microsoft d'établir un monopole dans la vente de logiciels. Cela fut condamné pour [[w:Abus_de_position_dominante|abus de position dominante]]<ref name="Combier_2018_01_24">{{Lien web|langue=fr|auteur=Étienne Combier|titre=Abus de position dominante : les plus grosses amendes de la Commission européenne|url=https://web.archive.org/web/20230511110018/https://www.lesechos.fr/2018/01/abus-de-position-dominante-les-plus-grosses-amendes-de-la-commission-europeenne-982719|périodique=[[w:Les Échos|Les Échos]]|date=2018-01-24|consulté le=}}.</ref> et [[w:Vente_liée_de_logiciels_avec_du_matériel_informatique|vente liée du logiciel avec le matériel informatique]]<ref>{{Lien web|langue=fr|auteur=Marc Rees|titre=Pourquoi la justice européenne a sanctuarisé la vente liée PC et OS|url=https://web.archive.org/web/20230209112015/https://www.nextinpact.com/article/23625/101268-la-justice-europeenne-sanctuarise-vente-liee-pc-et-os|site=nextinpact.com|éditeur=[[w:Next INpact|Next INpact]]|date=2016-07-09|consulté le=}}.</ref>, mais sans pour autant empêcher [[w:Bill_Gates|Bill Gates]], le principal actionnaire de Microsoft, d'être l'homme le plus riche du monde en 1994. [[Fichier:GNU_and_Tux.svg|alt=Mascotte du projet GNU à gauche et du projet Linux à droite.|vignette|<small>Figure 5. À gauche la mascotte du projet GNU ; à droite celle du projet Linux, appelée Tux.</small>]] Toutefois, pendant que Microsoft renforçait sa position dominante, un nouvel événement majeur allait marquer l’histoire du logiciel libre. Celui-ci fut de nouveau déclenché par un appel à contribution, qui fut cette fois posté le vingt-cinq août 1991 par un jeune étudiant en informatique de 21 ans, appelé [[w:fr:Linus Torvalds|Linus Torvalds]]. Via le système de messagerie [[w:fr:Usenet|Usenet]], sa demande avait été posté dans une liste de diffusion consacrée au système d’exploitation [[w:fr:Minix|Minix]], une sorte d’[[w:UNIX|UNIX]] simplifié et développé dans un but didactique, par le programmeur [[w:fr:Andrew Tanenbaum|Andrew Tanenbaum]]. Loin d’imaginer que cela ferait de lui une nouvelle célébrité dans le monde du Libre<ref>{{Ouvrage|langue=|prénom1=Linus|nom1=Torvalds|prénom2=David|nom2=Diamond|prénom3=Olivier|nom3=Engler|titre=Il était une fois Linux|éditeur=Osman Eyrolles Multimédia|date=2001|isbn=978-2-7464-0321-5|oclc=48059105}}.</ref>, Torvalds entama son message par le paragraphe suivant<ref>{{Ouvrage|langue=|prénom1=Linus|nom1=Torvalds|prénom2=David|nom2=Diamond|titre=Just for fun : the story of an accidental revolutionary|éditeur=HarperBusiness|date=2002|isbn=978-0-06-662073-2|oclc=1049937833}}.</ref> : <blockquote> Je fais un système d’exploitation (gratuit) (juste un hobby, ne sera pas grand et professionnel comme gnu) pour les clones 386 (486) AT. Ce projet est en cours depuis avril et commence à se préparer. J’aimerais avoir un retour sur ce que les gens aiment ou n’aiment pas dans minix, car mon système d’exploitation lui ressemble un peu (même disposition physique du système de fichiers (pour des raisons pratiques) entre autres choses)<ref>Texte original avant sa traduction par www.deepl.com/translator : « ''I'm doing a (free) operating system (just a hobby, won't be big and professional like gnu) for 386(486) AT clones. This has been brewing since april, and is starting to get ready. I'd like any feedback on things people like/dislike in minix, as my OS resembles it somewhat (same physical layout of the file-system (due to practical reasons)among other things) ».''</ref>. </blockquote> Bien qu’il fût présenté comme un passe-temps, le projet qui répondait au nom de « [[w:fr:Noyau Linux|Linux]] », fut rapidement soutenu par des milliers de programmeurs du monde entier, avant de devenir la pièce manquante du projet GNU. En effet, les contributeurs au projet de Stallman n’avaient pas encore terminé l’écriture du code informatique du [[w:noyau_de_système_d'exploitation|noyau]] [[w:GNU Hurd|Hurd]], alors qu'il était censé établir la communication entre la [[w:Suite_logicielle|suite logicielle]] produite par GNU et le [[w:Matériel informatique|matériel informatique]]. C'est donc la fusion des codes produits par les projets GNU et Linux qui permit la création du premier système complet, stable et entièrement libre baptisé [[w:GNU/Linux|GNU/Linux]]. [[Fichier:Debian-OpenLogo.svg|gauche|vignette|<small>Figure 6. Logo du système d’exploitation Debian.</small>|264x264px]] Au départ de ce nouveau système informatique, de nombreuses variantes, que l’on nomme communément « [[w:Distribution_Linux|distributions]] », furent créées par des programmeurs de tous horizons. L’une de celles-ci s’intitule [[w:fr:Debian|Debian]] et tire sa réputation d'être simultanément libre, gratuite, très fiable et produite par une communauté sans lien direct avec une société commerciale<ref>{{Ouvrage|langue=|auteur=|prénom1=Christophe|nom1=Lazaro|titre=La liberte logicielle|passage=|lieu=|éditeur=Academia Bruylant|collection=Anthropologie Prospective|date=2012|pages totales=56|isbn=978-2-87209-861-3|oclc=1104281978}}.</ref>. Quatre qualités qui expliquent pourquoi, Debian est utilisé dans plus de 150 distributions dérivées, mais aussi par de nombreuses entreprises et organisations, à l’image de la [[w:Wikimedia_Foundation|Fondation Wikimédia]], qui l’utilise sur [[metawiki:Wikimedia_servers/fr|ses serveurs]], pour héberger les projets qu'elle supporte<ref>{{Lien web|langue=|auteur=Méta-Wiki|titre=Serveurs Wikimedia|url=https://web.archive.org/web/20251113214321/https://meta.wikimedia.org/wiki/Wikimedia_servers/fr|site=|date=|consulté le=}}.</ref>. Grâce à la naissance des logiciels libres, le mouvement Wikimédia a donc la possibilité de faire tourner ses serveurs informatiques, avec un système d’exploitation fiable, libre et gratuit. Comme son [[w:Code_source|code source⁣⁣]] est ouvert, cela permet aussi à la Fondation Wikimédia de le modifier pour répondre aux besoins spécifiques du mouvement. À la suite de quoi, et selon les règles formulées par la [[w:Communauté_du_logiciel_libre|communauté du logiciel libre]], les modifications faites par la Fondation deviennent à leur tour, gratuitement et librement, utilisables par d’autres personnes ou organismes. À ce premier héritage reçu par le mouvement Wikimédia et toujours en provenance des logiciels libres, s’ajoute une innovation méthodologique. Dans son article ''[[w:La_Cathédrale_et_le_Bazar|La Cathédrale et le Bazar]]''<ref>{{Ouvrage|langue=|auteur=|prénom1=Eric Steven|nom1=Raymond|titre=Cathedral and the bazaar|titre original=Cathedral and the bazaar|traduction titre=La cathédrale et le bazar|passage=|lieu=|éditeur=SnowBall Publishing|date=2010|pages totales=|isbn=978-1-60796-228-1|oclc=833142152|lire en ligne=}}.</ref>, [[w:Eric_Raymond|Eric Raymond]] mobilise en effet le terme « [[w:Cathédrale|cathédrale]] » pour désigner le mode de production des logiciels propriétaires, en opposition au mot « [[w:fr:Bazar|bazar]] », qu'il utilise pour qualifier le mode de développement des logiciels libres. D’un côté, il décrit une organisation pyramidale, rigide et statutairement hiérarchisée, comme on peut la voir souvent au sein des entreprises. Tandis que de l’autre, il parle d’une organisation horizontale, flexible et peu hiérarchisée, qu’il a lui-même expérimentée en adoptant le style de développement de Linus Torvalds, à savoir : « distribuez vite et souvent, déléguez tout ce que vous pouvez déléguer, soyez ouvert jusqu’à la promiscuité »<ref>{{Lien web|langue=|auteur=Eric S. Raymond|traducteur=Sébastien Blondeel|titre=La cathédrale et le bazar|url=https://web.archive.org/web/20200203054716/http://www.linux-france.org/article/these/cathedrale-bazar/cathedrale-bazar-1.html|site=Linux France|lieu=|date=1998|consulté le=}}.</ref>. À l’instar de la métaphore du quartier numérique présentée dans le précédent chapitre, cette manière de décrire les projets open source nous aide donc ici à mieux comprendre ce qui se passe dans le mouvement Wikimédia. D'un côté, on retrouve effectivement cette « ouverture jusqu’à la promiscuité », dans le libre accès accordé aux projets Wikimédia, alors que de l'autre, tout le monde peut participer aux projets Wikimédia, qu'ils soient en ligne ou hors ligne. Ces deux observations corroborent donc l’existence d’un deuxième héritage, en provenance du mouvement du logiciel libre. Néanmoins, il nous reste encore à découvrir un phénomène négligé par Eric Raymond durant ses observations, et qui a pourtant une importance considérable dans l'histoire de la révolution numérique. Il s’agit là de l’apparition de la licence libre, de la philosophie qu'elle sous-tend, et de la [[w:Culture_libre|culture libre]] dont elle fut à l’origine. {{AutoCat}} n530h2hwrmoxhstnsanhom8kxwyxmbu 765181 765180 2026-04-27T07:01:42Z Lionel Scheepmans 20012 765181 wikitext text/x-wiki <noinclude>{{Le mouvement Wikimédia}}</noinclude> L’un des premiers épisodes de la préhistoire de Wikipédia et du mouvement Wikimédia débuta en septembre 1983, lorsqu’un programmeur du ''[[w:fr:Massachusetts Institute of Technology|Massachusetts Institute of Technology]]'', appelé [[w:fr:Richard Stallman|Richard Stallman]], déposa un message sur la liste de diffusion net.unix-wizards. C’était un appel d’aide pour la création de [[w:Projet GNU|GNU]], un nouveau [[w:fr:Système d'exploitation|système d’exploitation]] qui devait réunir une suite de programmes que tout le monde pourrait utiliser librement sur son ordinateur personnel<ref>{{Ouvrage|langue=|prénom1=Richard M|nom1=Stallman|prénom2=Sam|nom2=Williams|titre=Richard Stallman et la révolution du logiciel libre - Une biographie autorisée|éditeur=Eyrolles|date=2013|oclc=708380925|lire en ligne=https://framabook.org/docs/stallman/framabook6_stallman_v1_gnu-fdl.pdf|consulté le=}}.</ref>. Dans son message transmis via [[w:Arpanet|ARPANET]], le premier réseau informatique à grande échelle qui précéda Internet, Stallman s’exprimait de la sorte<ref>{{Lien web|langue=|auteur=Richard Stallman|titre=Système d'exploitation GNU – Annonce initiale|url=https://web.archive.org/web/20010106133800/http://www.gnu.org:80/gnu/initial-announcement.fr.html|site=GNU|date=3 décembre 2000|consulté le=}}.</ref> : <blockquote> Je considère comme une [[w:Règle d'or|règle d’or]] que si j’apprécie un programme je dois le partager avec d’autres personnes qui l’apprécient. Je ne peux pas en bonne conscience signer un accord de non-divulgation ni un accord de licence de logiciel. Afin de pouvoir continuer à utiliser les ordinateurs sans violer mes principes, j’ai décidé de rassembler une quantité suffisante de logiciels libres, de manière à pouvoir m’en tirer sans aucun logiciel qui ne soit pas libre. </blockquote> Le projet de Stallman, qui reçut le soutien nécessaire à son accomplissement, marqua ainsi le début de l’[[w:Histoire_du_logiciel_libre|histoire du logiciel libre]]. Quant à la quantité d’aide fournie, elle permet de croire que Richard Stallman n’était pas seul à voir l’arrivée des [[w:Logiciel propriétaire|logiciels propriétaires]] d’un mauvais œil. Car pour les membres du projet GNU et du mouvement du logiciel libre en général, un bon programme informatique doit respecter ces quatre libertés fondamentales<ref>{{Lien web|langue=|auteur=Karl Pradène|titre=Qu'est-ce que le logiciel libre ?|url=https://web.archive.org/web/20000511101640/http://www.gnu.org/philosophy/free-sw.fr.html|site=GNU|date=6 mai 2000|consulté le=}}.</ref> : <blockquote> 1. La liberté d’exécuter le programme, pour tous les usages. 2. La liberté d’étudier le fonctionnement du programme, et de l’adapter à vos besoins. 3. La liberté de redistribuer des copies, donc d’aider votre voisin. 4. La liberté d’améliorer le programme, et de publier vos améliorations, pour en faire profiter toute la communauté. </blockquote> [[w:Histoire_du_logiciel_libre|Lors de l'apparition du logiciel libre]], le marché de l’informatique était de fait en pleine mutation. L'habituel partage des codes informatiques entre les rares étudiants ou chercheurs qui bénéficiaient d’un accès à un ordinateur faisait l'objet d'une remise en question. Ce changement faisait notamment suite au [[w:Copyright_Act_(1976)|Copyright Act]] de 1976, une nouvelle loi qui autorisait l'application d'un [[w:Droit_d'auteur|droit d'auteur]] sur le code informatique, et donc qui permettait d'en interdire le partage ou la réutilisation sans autorisation. Des [[w:Clause_de_confidentialité|clauses de confidentialité]] ont ainsi fait leur apparition, pendant que les employés des firmes informatiques étaient nouvellement soumis à des contrats de confidentialité. C'était la fin de l’entraide et de la solidarité pratiquées chez les pionniers de l’informatique. À sa place s'installaient la concurrence et la compétitivité, bien connues dans le système capitaliste marchand. [[Fichier:Commodore64withdisk.jpg|alt=Commodore 64 avec disquette et lecteur|gauche|vignette|<small>Figure 4. Commodore 64 avec disquette et lecteur.</small>|300x300px]] Cette mutation coïncidait avec l’arrivée des premiers ordinateurs de taille réduite. Grâce à l’apparition des premiers [[w:Circuits_intégrés|circuits intégrés⁣⁣]], les premiers exemplaires avaient en effet été créés par l’industrie aérospatiale au début des années 1960. Cependant, il fallut attendre le début des années 1980 pour que le prix d’un ordinateur soit suffisamment bas pour en faire un [[w:Bien_de_grande_consommation|bien de grande consommation]]. C’est ainsi qu’en 1982, le [[w:Commodore 64|Commodore 64]] entrait dans le [[w:Livre_Guinness_des_records|livre Guinness des records]], avec plus de 17 millions d’exemplaires vendus dans le monde<ref>{{Lien web|langue=|auteur=Brandon Griggs|titre=The Commodore 64, that '80 s computer icon, lives again|url=https://web.archive.org/web/20200706161515/http://edition.cnn.com/2011/TECH/gaming.gadgets/05/09/commodore.64.reborn|site=CNN|date=May 9, 2011|consulté le=}}.</ref>. Juste avant cela, en 1981, l’''[[w:fr:IBM PC|IBM Personal Computer]]'' avait déjà fait son apparition, en proposant une [[w:Architecture_(informatique)|architecture]] ouverte qui allait servir de modèle pour toute une gamme d’ordinateurs que l’on désigne toujours aujourd’hui par l’acronyme « PC ». Pour faire fonctionner ses nouveaux modèles d'ordinateurs, la société IBM avait confié à l’entreprise [[w:Microsoft|Microsoft]], créée en 1975, la mission de les équiper d’un système d’exploitation. Le contrat signé entre les deux firmes fut une véritable aubaine pour le fournisseur des programmes informatiques. Car sans s'en apercevoir, et sans jamais anticiper que son matériel serait cloné à grande échelle, celle-ci avait en effet permis à Microsoft d'établir un monopole dans la vente de logiciels. Cela fut condamné pour [[w:Abus_de_position_dominante|abus de position dominante]]<ref name="Combier_2018_01_24">{{Lien web|langue=fr|auteur=Étienne Combier|titre=Abus de position dominante : les plus grosses amendes de la Commission européenne|url=https://web.archive.org/web/20230511110018/https://www.lesechos.fr/2018/01/abus-de-position-dominante-les-plus-grosses-amendes-de-la-commission-europeenne-982719|périodique=[[w:Les Échos|Les Échos]]|date=2018-01-24|consulté le=}}.</ref> et [[w:Vente_liée_de_logiciels_avec_du_matériel_informatique|vente liée du logiciel avec le matériel informatique]]<ref>{{Lien web|langue=fr|auteur=Marc Rees|titre=Pourquoi la justice européenne a sanctuarisé la vente liée PC et OS|url=https://web.archive.org/web/20230209112015/https://www.nextinpact.com/article/23625/101268-la-justice-europeenne-sanctuarise-vente-liee-pc-et-os|site=nextinpact.com|éditeur=[[w:Next INpact|Next INpact]]|date=2016-07-09|consulté le=}}.</ref>, mais sans pour autant empêcher [[w:Bill_Gates|Bill Gates]], le principal actionnaire de Microsoft, d'être l'homme le plus riche du monde en 1994. [[Fichier:GNU_and_Tux.svg|alt=Mascotte du projet GNU à gauche et du projet Linux à droite.|vignette|<small>Figure 5. À gauche la mascotte du projet GNU ; à droite celle du projet Linux, appelée Tux.</small>]] Toutefois, pendant que Microsoft renforçait sa position dominante, un nouvel événement majeur allait marquer l’histoire du logiciel libre. Celui-ci fut de nouveau déclenché par un appel à contribution, qui fut cette fois posté le vingt-cinq août 1991 par un jeune étudiant en informatique de 21 ans, appelé [[w:fr:Linus Torvalds|Linus Torvalds]]. Via le système de messagerie [[w:fr:Usenet|Usenet]], sa demande avait été posté dans une liste de diffusion consacrée au système d’exploitation [[w:fr:Minix|Minix]], une sorte d’[[w:UNIX|UNIX]] simplifié et développé dans un but didactique, par le programmeur [[w:fr:Andrew Tanenbaum|Andrew Tanenbaum]]. Loin d’imaginer que cela ferait de lui une nouvelle célébrité dans le monde du Libre<ref>{{Ouvrage|langue=|prénom1=Linus|nom1=Torvalds|prénom2=David|nom2=Diamond|prénom3=Olivier|nom3=Engler|titre=Il était une fois Linux|éditeur=Osman Eyrolles Multimédia|date=2001|isbn=978-2-7464-0321-5|oclc=48059105}}.</ref>, Torvalds entama son message par le paragraphe suivant<ref>{{Ouvrage|langue=|prénom1=Linus|nom1=Torvalds|prénom2=David|nom2=Diamond|titre=Just for fun : the story of an accidental revolutionary|éditeur=HarperBusiness|date=2002|isbn=978-0-06-662073-2|oclc=1049937833}}.</ref> : <blockquote> Je fais un système d’exploitation (gratuit) (juste un hobby, ne sera pas grand et professionnel comme gnu) pour les clones 386 (486) AT. Ce projet est en cours depuis avril et commence à se préparer. J’aimerais avoir un retour sur ce que les gens aiment ou n’aiment pas dans minix, car mon système d’exploitation lui ressemble un peu (même disposition physique du système de fichiers (pour des raisons pratiques) entre autres choses)<ref>Texte original avant sa traduction par www.deepl.com/translator : « ''I'm doing a (free) operating system (just a hobby, won't be big and professional like gnu) for 386(486) AT clones. This has been brewing since april, and is starting to get ready. I'd like any feedback on things people like/dislike in minix, as my OS resembles it somewhat (same physical layout of the file-system (due to practical reasons)among other things) ».''</ref>. </blockquote> Bien qu’il fût présenté comme un passe-temps, le projet qui répondait au nom de « [[w:fr:Noyau Linux|Linux]] », fut rapidement soutenu par des milliers de programmeurs du monde entier, avant de devenir la pièce manquante du projet GNU. En effet, les contributeurs au projet de Stallman n’avaient pas encore terminé l’écriture du code informatique du [[w:noyau_de_système_d'exploitation|noyau]] [[w:GNU Hurd|Hurd]], alors qu'il était censé établir la communication entre la [[w:Suite_logicielle|suite logicielle]] produite par GNU et le [[w:Matériel informatique|matériel informatique]]. C'est donc la fusion des codes produits par les projets GNU et Linux qui permit la création du premier système complet, stable et entièrement libre baptisé [[w:GNU/Linux|GNU/Linux]]. [[Fichier:Debian-OpenLogo.svg|gauche|vignette|<small>Figure 6. Logo du système d’exploitation Debian.</small>|264x264px]] Au départ de ce nouveau système informatique, de nombreuses variantes, que l’on nomme communément « [[w:Distribution_Linux|distributions]] », furent créées par des programmeurs de tous horizons. L’une de celles-ci s’intitule [[w:fr:Debian|Debian]] et tire sa réputation d'être simultanément libre, gratuite, très fiable et produite par une communauté sans lien direct avec une société commerciale<ref>{{Ouvrage|langue=|auteur=|prénom1=Christophe|nom1=Lazaro|titre=La liberte logicielle|passage=|lieu=|éditeur=Academia Bruylant|collection=Anthropologie Prospective|date=2012|pages totales=56|isbn=978-2-87209-861-3|oclc=1104281978}}.</ref>. Quatre qualités qui expliquent pourquoi, Debian est utilisé dans plus de 150 distributions dérivées, mais aussi par de nombreuses entreprises et organisations, à l’image de la [[w:Wikimedia_Foundation|Fondation Wikimédia]], qui l’utilise sur [[metawiki:Wikimedia_servers/fr|ses serveurs]], pour héberger les projets qu'elle supporte<ref>{{Lien web|langue=|auteur=Méta-Wiki|titre=Serveurs Wikimedia|url=https://web.archive.org/web/20251113214321/https://meta.wikimedia.org/wiki/Wikimedia_servers/fr|site=|date=|consulté le=}}.</ref>. Grâce à la naissance des logiciels libres, le mouvement Wikimédia a donc la possibilité de faire tourner ses serveurs informatiques, avec un système d’exploitation fiable, libre et gratuit. Comme son [[w:Code_source|code source⁣⁣]] est ouvert, cela permet aussi à la Fondation Wikimédia de le modifier pour répondre aux besoins spécifiques du mouvement. À la suite de quoi, et selon les règles formulées par la [[w:Communauté_du_logiciel_libre|communauté du logiciel libre]], les modifications faites par la Fondation deviennent à leur tour, gratuitement et librement, utilisables par d’autres personnes ou organismes. À ce premier héritage reçu par le mouvement Wikimédia et toujours en provenance des logiciels libres, s’ajoute une innovation méthodologique. Dans son article ''[[w:La_Cathédrale_et_le_Bazar|La Cathédrale et le Bazar]]''<ref>{{Ouvrage|langue=|auteur=|prénom1=Eric Steven|nom1=Raymond|titre=Cathedral and the bazaar|titre original=Cathedral and the bazaar|traduction titre=La cathédrale et le bazar|passage=|lieu=|éditeur=SnowBall Publishing|date=2010|pages totales=|isbn=978-1-60796-228-1|oclc=833142152|lire en ligne=}}.</ref>, [[w:Eric_Raymond|Eric Raymond]] mobilise en effet le terme « [[w:Cathédrale|cathédrale]] » pour désigner le mode de production des logiciels propriétaires, en opposition au mot « [[w:fr:Bazar|bazar]] », qu'il utilise pour qualifier le mode de développement des logiciels libres. D’un côté, il décrit une organisation pyramidale, rigide et statutairement hiérarchisée, comme on peut la voir souvent au sein des entreprises. Tandis que de l’autre, il parle d’une organisation horizontale, flexible et peu hiérarchisée, qu’il a lui-même expérimentée en adoptant le style de développement de Linus Torvalds, à savoir : « distribuez vite et souvent, déléguez tout ce que vous pouvez déléguer, soyez ouvert jusqu’à la promiscuité »<ref>{{Lien web|langue=|auteur=Eric S. Raymond|traducteur=Sébastien Blondeel|titre=La cathédrale et le bazar|url=https://web.archive.org/web/20200203054716/http://www.linux-france.org/article/these/cathedrale-bazar/cathedrale-bazar-1.html|site=Linux France|lieu=|date=1998|consulté le=}}.</ref>. À l’instar de la métaphore du quartier numérique présentée dans le précédent chapitre, cette manière de décrire les projets open source nous aide donc ici à mieux comprendre ce qui se passe dans le mouvement Wikimédia. D'un côté, on retrouve effectivement cette « ouverture jusqu’à la promiscuité », dans le libre accès accordé aux projets Wikimédia, alors que de l'autre, tout le monde peut participer aux projets Wikimédia, qu'ils soient en ligne ou hors ligne. Ces deux observations corroborent donc l’existence d’un deuxième héritage, en provenance du mouvement du logiciel libre. Néanmoins, il nous reste encore à découvrir un phénomène négligé par Eric Raymond durant ses observations, et qui pourtant, une important, a considérable influencé l'histoire de la révolution numérique. Il s’agit là de l’apparition de la licence libre, de la philosophie qu'elle sous-tend, et de la [[w:Culture_libre|culture libre]] dont elle fut à l’origine. {{AutoCat}} bqhe2849qc8wi717tekk2bmwb89zc5g 765182 765181 2026-04-27T07:13:00Z Lionel Scheepmans 20012 765182 wikitext text/x-wiki <noinclude>{{Le mouvement Wikimédia}}</noinclude> L’un des premiers épisodes de la préhistoire de Wikipédia et du mouvement Wikimédia débuta en septembre 1983, lorsqu’un programmeur du ''[[w:fr:Massachusetts Institute of Technology|Massachusetts Institute of Technology]]'', appelé [[w:fr:Richard Stallman|Richard Stallman]], déposa un message sur la liste de diffusion net.unix-wizards. C’était un appel d’aide pour la création de [[w:Projet GNU|GNU]], un nouveau [[w:fr:Système d'exploitation|système d’exploitation]] qui devait réunir une suite de programmes que tout le monde pourrait utiliser librement sur son ordinateur personnel<ref>{{Ouvrage|langue=|prénom1=Richard M|nom1=Stallman|prénom2=Sam|nom2=Williams|titre=Richard Stallman et la révolution du logiciel libre - Une biographie autorisée|éditeur=Eyrolles|date=2013|oclc=708380925|lire en ligne=https://framabook.org/docs/stallman/framabook6_stallman_v1_gnu-fdl.pdf|consulté le=}}.</ref>. Dans son message transmis via [[w:Arpanet|ARPANET]], le premier réseau informatique à grande échelle qui précéda Internet, Stallman s’exprimait de la sorte<ref>{{Lien web|langue=|auteur=Richard Stallman|titre=Système d'exploitation GNU – Annonce initiale|url=https://web.archive.org/web/20010106133800/http://www.gnu.org:80/gnu/initial-announcement.fr.html|site=GNU|date=3 décembre 2000|consulté le=}}.</ref> : <blockquote> Je considère comme une [[w:Règle d'or|règle d’or]] que si j’apprécie un programme je dois le partager avec d’autres personnes qui l’apprécient. Je ne peux pas en bonne conscience signer un accord de non-divulgation ni un accord de licence de logiciel. Afin de pouvoir continuer à utiliser les ordinateurs sans violer mes principes, j’ai décidé de rassembler une quantité suffisante de logiciels libres, de manière à pouvoir m’en tirer sans aucun logiciel qui ne soit pas libre. </blockquote> Le projet de Stallman, qui reçut le soutien nécessaire à son accomplissement, marqua ainsi le début de l’[[w:Histoire_du_logiciel_libre|histoire du logiciel libre]]. Quant à la quantité d’aide fournie, elle permet de croire que Richard Stallman n’était pas seul à voir l’arrivée des [[w:Logiciel propriétaire|logiciels propriétaires]] d’un mauvais œil. Car pour les membres du projet GNU et du mouvement du logiciel libre en général, un bon programme informatique doit respecter ces quatre libertés fondamentales<ref>{{Lien web|langue=|auteur=Karl Pradène|titre=Qu'est-ce que le logiciel libre ?|url=https://web.archive.org/web/20000511101640/http://www.gnu.org/philosophy/free-sw.fr.html|site=GNU|date=6 mai 2000|consulté le=}}.</ref> : <blockquote> 1. La liberté d’exécuter le programme, pour tous les usages. 2. La liberté d’étudier le fonctionnement du programme, et de l’adapter à vos besoins. 3. La liberté de redistribuer des copies, donc d’aider votre voisin. 4. La liberté d’améliorer le programme, et de publier vos améliorations, pour en faire profiter toute la communauté. </blockquote> [[w:Histoire_du_logiciel_libre|Lors de l'apparition du logiciel libre]], le marché de l’informatique était de fait en pleine mutation. L'habituel partage des codes informatiques entre les rares étudiants ou chercheurs qui bénéficiaient d’un accès à un ordinateur faisait l'objet d'une remise en question. Ce changement faisait notamment suite au [[w:Copyright_Act_(1976)|Copyright Act]] de 1976, une nouvelle loi qui autorisait l'application d'un [[w:Droit_d'auteur|droit d'auteur]] sur le code informatique, et donc qui permettait d'en interdire le partage ou la réutilisation sans autorisation. Des [[w:Clause_de_confidentialité|clauses de confidentialité]] ont ainsi fait leur apparition, pendant que les employés des firmes informatiques étaient nouvellement soumis à des contrats de confidentialité. C'était la fin de l’entraide et de la solidarité pratiquées chez les pionniers de l’informatique. À sa place s'installaient la concurrence et la compétitivité, bien connues dans le système capitaliste marchand. [[Fichier:Commodore64withdisk.jpg|alt=Commodore 64 avec disquette et lecteur|gauche|vignette|<small>Figure 4. Commodore 64 avec disquette et lecteur.</small>|300x300px]] Cette mutation coïncidait avec l’arrivée des premiers ordinateurs de taille réduite. Grâce à l’apparition des premiers [[w:Circuits_intégrés|circuits intégrés⁣⁣]], les premiers exemplaires avaient en effet été créés par l’industrie aérospatiale au début des années 1960. Cependant, il fallut attendre le début des années 1980 pour que le prix d’un ordinateur soit suffisamment bas pour en faire un [[w:Bien_de_grande_consommation|bien de grande consommation]]. C’est ainsi qu’en 1982, le [[w:Commodore 64|Commodore 64]] entrait dans le [[w:Livre_Guinness_des_records|livre Guinness des records]], avec plus de 17 millions d’exemplaires vendus dans le monde<ref>{{Lien web|langue=|auteur=Brandon Griggs|titre=The Commodore 64, that '80 s computer icon, lives again|url=https://web.archive.org/web/20200706161515/http://edition.cnn.com/2011/TECH/gaming.gadgets/05/09/commodore.64.reborn|site=CNN|date=May 9, 2011|consulté le=}}.</ref>. Juste avant cela, en 1981, l’''[[w:fr:IBM PC|IBM Personal Computer]]'' avait déjà fait son apparition, en proposant une [[w:Architecture_(informatique)|architecture]] ouverte qui allait servir de modèle pour toute une gamme d’ordinateurs que l’on désigne toujours aujourd’hui par l’acronyme « PC ». Pour faire fonctionner ses nouveaux modèles d'ordinateurs, la société IBM avait confié à l’entreprise [[w:Microsoft|Microsoft]], créée en 1975, la mission de les équiper d’un système d’exploitation. Le contrat signé entre les deux firmes fut une véritable aubaine pour le fournisseur des programmes informatiques. Car sans s'en apercevoir, et sans jamais anticiper que son matériel serait cloné à grande échelle, celle-ci avait en effet permis à Microsoft d'établir un monopole dans la vente de logiciels. Cela fut condamné pour [[w:Abus_de_position_dominante|abus de position dominante]]<ref name="Combier_2018_01_24">{{Lien web|langue=fr|auteur=Étienne Combier|titre=Abus de position dominante : les plus grosses amendes de la Commission européenne|url=https://web.archive.org/web/20230511110018/https://www.lesechos.fr/2018/01/abus-de-position-dominante-les-plus-grosses-amendes-de-la-commission-europeenne-982719|périodique=[[w:Les Échos|Les Échos]]|date=2018-01-24|consulté le=}}.</ref> et [[w:Vente_liée_de_logiciels_avec_du_matériel_informatique|vente liée du logiciel avec le matériel informatique]]<ref>{{Lien web|langue=fr|auteur=Marc Rees|titre=Pourquoi la justice européenne a sanctuarisé la vente liée PC et OS|url=https://web.archive.org/web/20230209112015/https://www.nextinpact.com/article/23625/101268-la-justice-europeenne-sanctuarise-vente-liee-pc-et-os|site=nextinpact.com|éditeur=[[w:Next INpact|Next INpact]]|date=2016-07-09|consulté le=}}.</ref>, mais sans pour autant empêcher [[w:Bill_Gates|Bill Gates]], le principal actionnaire de Microsoft, d'être l'homme le plus riche du monde en 1994. [[Fichier:GNU_and_Tux.svg|alt=Mascotte du projet GNU à gauche et du projet Linux à droite.|vignette|<small>Figure 5. À gauche la mascotte du projet GNU ; à droite celle du projet Linux, appelée Tux.</small>]] Toutefois, pendant que Microsoft renforçait sa position dominante, un nouvel événement majeur allait marquer l’histoire du logiciel libre. Celui-ci fut de nouveau déclenché par un appel à contribution, qui fut cette fois posté le vingt-cinq août 1991 par un jeune étudiant en informatique de 21 ans, appelé [[w:fr:Linus Torvalds|Linus Torvalds]]. Via le système de messagerie [[w:fr:Usenet|Usenet]], sa demande avait été posté dans une liste de diffusion consacrée au système d’exploitation [[w:fr:Minix|Minix]], une sorte d’[[w:UNIX|UNIX]] simplifié et développé dans un but didactique, par le programmeur [[w:fr:Andrew Tanenbaum|Andrew Tanenbaum]]. Loin d’imaginer que cela ferait de lui une nouvelle célébrité dans le monde du Libre<ref>{{Ouvrage|langue=|prénom1=Linus|nom1=Torvalds|prénom2=David|nom2=Diamond|prénom3=Olivier|nom3=Engler|titre=Il était une fois Linux|éditeur=Osman Eyrolles Multimédia|date=2001|isbn=978-2-7464-0321-5|oclc=48059105}}.</ref>, Torvalds entama son message par le paragraphe suivant<ref>{{Ouvrage|langue=|prénom1=Linus|nom1=Torvalds|prénom2=David|nom2=Diamond|titre=Just for fun : the story of an accidental revolutionary|éditeur=HarperBusiness|date=2002|isbn=978-0-06-662073-2|oclc=1049937833}}.</ref> : <blockquote> Je fais un système d’exploitation (gratuit) (juste un hobby, ne sera pas grand et professionnel comme gnu) pour les clones 386 (486) AT. Ce projet est en cours depuis avril et commence à se préparer. J’aimerais avoir un retour sur ce que les gens aiment ou n’aiment pas dans minix, car mon système d’exploitation lui ressemble un peu (même disposition physique du système de fichiers (pour des raisons pratiques) entre autres choses)<ref>Texte original avant sa traduction par www.deepl.com/translator : « ''I'm doing a (free) operating system (just a hobby, won't be big and professional like gnu) for 386(486) AT clones. This has been brewing since april, and is starting to get ready. I'd like any feedback on things people like/dislike in minix, as my OS resembles it somewhat (same physical layout of the file-system (due to practical reasons)among other things) ».''</ref>. </blockquote> Bien qu’il fût présenté comme un passe-temps, le projet qui répondait au nom de « [[w:fr:Noyau Linux|Linux]] », fut rapidement soutenu par des milliers de programmeurs du monde entier, avant de devenir la pièce manquante du projet GNU. En effet, les contributeurs au projet de Stallman n’avaient pas encore terminé l’écriture du code informatique du [[w:noyau_de_système_d'exploitation|noyau]] [[w:GNU Hurd|Hurd]], alors qu'il était censé établir la communication entre la [[w:Suite_logicielle|suite logicielle]] produite par GNU et le [[w:Matériel informatique|matériel informatique]]. C'est donc la fusion des codes produits par les projets GNU et Linux qui permit la création du premier système complet, stable et entièrement libre baptisé [[w:GNU/Linux|GNU/Linux]]. [[Fichier:Debian-OpenLogo.svg|gauche|vignette|<small>Figure 6. Logo du système d’exploitation Debian.</small>|264x264px]] Au départ de ce nouveau système informatique, de nombreuses variantes, que l’on nomme communément « [[w:Distribution_Linux|distributions]] », furent créées par des programmeurs de tous horizons. L’une de celles-ci s’intitule [[w:fr:Debian|Debian]] et tire sa réputation d'être simultanément libre, gratuite, très fiable et produite par une communauté sans lien direct avec une société commerciale<ref>{{Ouvrage|langue=|auteur=|prénom1=Christophe|nom1=Lazaro|titre=La liberte logicielle|passage=|lieu=|éditeur=Academia Bruylant|collection=Anthropologie Prospective|date=2012|pages totales=56|isbn=978-2-87209-861-3|oclc=1104281978}}.</ref>. Quatre qualités qui expliquent pourquoi, Debian est utilisé dans plus de 150 distributions dérivées, mais aussi par de nombreuses entreprises et organisations, à l’image de la [[w:Wikimedia_Foundation|Fondation Wikimédia]], qui l’utilise sur [[metawiki:Wikimedia_servers/fr|ses serveurs]], pour héberger les projets qu'elle supporte<ref>{{Lien web|langue=|auteur=Méta-Wiki|titre=Serveurs Wikimedia|url=https://web.archive.org/web/20251113214321/https://meta.wikimedia.org/wiki/Wikimedia_servers/fr|site=|date=|consulté le=}}.</ref>. Grâce à la naissance des logiciels libres, le mouvement Wikimédia a donc la possibilité de faire tourner ses serveurs informatiques, avec un système d’exploitation fiable, libre et gratuit. Comme son [[w:Code_source|code source⁣⁣]] est ouvert, cela permet aussi à la Fondation Wikimédia de le modifier pour répondre aux besoins spécifiques du mouvement. À la suite de quoi, et selon les règles formulées par la [[w:Communauté_du_logiciel_libre|communauté du logiciel libre]], les modifications faites par la Fondation deviennent à leur tour, gratuitement et librement, utilisables par d’autres personnes ou organismes. À ce premier héritage reçu par le mouvement Wikimédia et toujours en provenance des logiciels libres, s’ajoute une innovation méthodologique. Dans son article ''[[w:La_Cathédrale_et_le_Bazar|La Cathédrale et le Bazar]]''<ref>{{Ouvrage|langue=|auteur=|prénom1=Eric Steven|nom1=Raymond|titre=Cathedral and the bazaar|titre original=Cathedral and the bazaar|traduction titre=La cathédrale et le bazar|passage=|lieu=|éditeur=SnowBall Publishing|date=2010|pages totales=|isbn=978-1-60796-228-1|oclc=833142152|lire en ligne=}}.</ref>, [[w:Eric_Raymond|Eric Raymond]] mobilise en effet le terme « [[w:Cathédrale|cathédrale]] » pour désigner le mode de production des logiciels propriétaires, en opposition au mot « [[w:fr:Bazar|bazar]] », qu'il utilise pour qualifier le mode de développement des logiciels libres. D’un côté, il décrit une organisation pyramidale, rigide et statutairement hiérarchisée, comme on peut la voir souvent au sein des entreprises. Tandis que de l’autre, il parle d’une organisation horizontale, flexible et peu hiérarchisée, qu’il a lui-même expérimentée en adoptant le style de développement de Linus Torvalds, à savoir : « distribuez vite et souvent, déléguez tout ce que vous pouvez déléguer, soyez ouvert jusqu’à la promiscuité »<ref>{{Lien web|langue=|auteur=Eric S. Raymond|traducteur=Sébastien Blondeel|titre=La cathédrale et le bazar|url=https://web.archive.org/web/20200203054716/http://www.linux-france.org/article/these/cathedrale-bazar/cathedrale-bazar-1.html|site=Linux France|lieu=|date=1998|consulté le=}}.</ref>. À l’instar de la métaphore du quartier numérique présentée dans le précédent chapitre, cette manière de décrire les projets open source nous aide donc ici à mieux comprendre ce qui se passe dans le mouvement Wikimédia. D'un côté, au niveau du libre accès accordé aux projets Wikimédia, on retrouve effectivement cette « ouverture jusqu’à la promiscuité », alors que de l'autre, qu'ils soient en ligne ou hors ligne, tout le monde peut participer aux projets Wikimédia. Ces deux observations corroborent donc l’existence d’un deuxième héritage, en provenance du mouvement du logiciel libre. Néanmoins, il nous reste encore à découvrir un phénomène négligé par Eric Raymond durant ses observations, et qui pourtant, a considérable influencé l'histoire de la révolution numérique. Il s’agit de l’apparition de la licence libre, de la philosophie de partage qu'elle sous-tend et du ouvementmde {{AutoCat}} jh7vhl25lctvrqj8bblt847hr6rl8v6 765183 765182 2026-04-27T07:18:09Z Lionel Scheepmans 20012 Annulation de la modification [[Special:Diff/765182|765182]] de [[Special:Contributions/Lionel Scheepmans|Lionel Scheepmans]] ([[User talk:Lionel Scheepmans|discussion]]) 765183 wikitext text/x-wiki <noinclude>{{Le mouvement Wikimédia}}</noinclude> L’un des premiers épisodes de la préhistoire de Wikipédia et du mouvement Wikimédia débuta en septembre 1983, lorsqu’un programmeur du ''[[w:fr:Massachusetts Institute of Technology|Massachusetts Institute of Technology]]'', appelé [[w:fr:Richard Stallman|Richard Stallman]], déposa un message sur la liste de diffusion net.unix-wizards. C’était un appel d’aide pour la création de [[w:Projet GNU|GNU]], un nouveau [[w:fr:Système d'exploitation|système d’exploitation]] qui devait réunir une suite de programmes que tout le monde pourrait utiliser librement sur son ordinateur personnel<ref>{{Ouvrage|langue=|prénom1=Richard M|nom1=Stallman|prénom2=Sam|nom2=Williams|titre=Richard Stallman et la révolution du logiciel libre - Une biographie autorisée|éditeur=Eyrolles|date=2013|oclc=708380925|lire en ligne=https://framabook.org/docs/stallman/framabook6_stallman_v1_gnu-fdl.pdf|consulté le=}}.</ref>. Dans son message transmis via [[w:Arpanet|ARPANET]], le premier réseau informatique à grande échelle qui précéda Internet, Stallman s’exprimait de la sorte<ref>{{Lien web|langue=|auteur=Richard Stallman|titre=Système d'exploitation GNU – Annonce initiale|url=https://web.archive.org/web/20010106133800/http://www.gnu.org:80/gnu/initial-announcement.fr.html|site=GNU|date=3 décembre 2000|consulté le=}}.</ref> : <blockquote> Je considère comme une [[w:Règle d'or|règle d’or]] que si j’apprécie un programme je dois le partager avec d’autres personnes qui l’apprécient. Je ne peux pas en bonne conscience signer un accord de non-divulgation ni un accord de licence de logiciel. Afin de pouvoir continuer à utiliser les ordinateurs sans violer mes principes, j’ai décidé de rassembler une quantité suffisante de logiciels libres, de manière à pouvoir m’en tirer sans aucun logiciel qui ne soit pas libre. </blockquote> Le projet de Stallman, qui reçut le soutien nécessaire à son accomplissement, marqua ainsi le début de l’[[w:Histoire_du_logiciel_libre|histoire du logiciel libre]]. Quant à la quantité d’aide fournie, elle permet de croire que Richard Stallman n’était pas seul à voir l’arrivée des [[w:Logiciel propriétaire|logiciels propriétaires]] d’un mauvais œil. Car pour les membres du projet GNU et du mouvement du logiciel libre en général, un bon programme informatique doit respecter ces quatre libertés fondamentales<ref>{{Lien web|langue=|auteur=Karl Pradène|titre=Qu'est-ce que le logiciel libre ?|url=https://web.archive.org/web/20000511101640/http://www.gnu.org/philosophy/free-sw.fr.html|site=GNU|date=6 mai 2000|consulté le=}}.</ref> : <blockquote> 1. La liberté d’exécuter le programme, pour tous les usages. 2. La liberté d’étudier le fonctionnement du programme, et de l’adapter à vos besoins. 3. La liberté de redistribuer des copies, donc d’aider votre voisin. 4. La liberté d’améliorer le programme, et de publier vos améliorations, pour en faire profiter toute la communauté. </blockquote> [[w:Histoire_du_logiciel_libre|Lors de l'apparition du logiciel libre]], le marché de l’informatique était de fait en pleine mutation. L'habituel partage des codes informatiques entre les rares étudiants ou chercheurs qui bénéficiaient d’un accès à un ordinateur faisait l'objet d'une remise en question. Ce changement faisait notamment suite au [[w:Copyright_Act_(1976)|Copyright Act]] de 1976, une nouvelle loi qui autorisait l'application d'un [[w:Droit_d'auteur|droit d'auteur]] sur le code informatique, et donc qui permettait d'en interdire le partage ou la réutilisation sans autorisation. Des [[w:Clause_de_confidentialité|clauses de confidentialité]] ont ainsi fait leur apparition, pendant que les employés des firmes informatiques étaient nouvellement soumis à des contrats de confidentialité. C'était la fin de l’entraide et de la solidarité pratiquées chez les pionniers de l’informatique. À sa place s'installaient la concurrence et la compétitivité, bien connues dans le système capitaliste marchand. [[Fichier:Commodore64withdisk.jpg|alt=Commodore 64 avec disquette et lecteur|gauche|vignette|<small>Figure 4. Commodore 64 avec disquette et lecteur.</small>|300x300px]] Cette mutation coïncidait avec l’arrivée des premiers ordinateurs de taille réduite. Grâce à l’apparition des premiers [[w:Circuits_intégrés|circuits intégrés⁣⁣]], les premiers exemplaires avaient en effet été créés par l’industrie aérospatiale au début des années 1960. Cependant, il fallut attendre le début des années 1980 pour que le prix d’un ordinateur soit suffisamment bas pour en faire un [[w:Bien_de_grande_consommation|bien de grande consommation]]. C’est ainsi qu’en 1982, le [[w:Commodore 64|Commodore 64]] entrait dans le [[w:Livre_Guinness_des_records|livre Guinness des records]], avec plus de 17 millions d’exemplaires vendus dans le monde<ref>{{Lien web|langue=|auteur=Brandon Griggs|titre=The Commodore 64, that '80 s computer icon, lives again|url=https://web.archive.org/web/20200706161515/http://edition.cnn.com/2011/TECH/gaming.gadgets/05/09/commodore.64.reborn|site=CNN|date=May 9, 2011|consulté le=}}.</ref>. Juste avant cela, en 1981, l’''[[w:fr:IBM PC|IBM Personal Computer]]'' avait déjà fait son apparition, en proposant une [[w:Architecture_(informatique)|architecture]] ouverte qui allait servir de modèle pour toute une gamme d’ordinateurs que l’on désigne toujours aujourd’hui par l’acronyme « PC ». Pour faire fonctionner ses nouveaux modèles d'ordinateurs, la société IBM avait confié à l’entreprise [[w:Microsoft|Microsoft]], créée en 1975, la mission de les équiper d’un système d’exploitation. Le contrat signé entre les deux firmes fut une véritable aubaine pour le fournisseur des programmes informatiques. Car sans s'en apercevoir, et sans jamais anticiper que son matériel serait cloné à grande échelle, celle-ci avait en effet permis à Microsoft d'établir un monopole dans la vente de logiciels. Cela fut condamné pour [[w:Abus_de_position_dominante|abus de position dominante]]<ref name="Combier_2018_01_24">{{Lien web|langue=fr|auteur=Étienne Combier|titre=Abus de position dominante : les plus grosses amendes de la Commission européenne|url=https://web.archive.org/web/20230511110018/https://www.lesechos.fr/2018/01/abus-de-position-dominante-les-plus-grosses-amendes-de-la-commission-europeenne-982719|périodique=[[w:Les Échos|Les Échos]]|date=2018-01-24|consulté le=}}.</ref> et [[w:Vente_liée_de_logiciels_avec_du_matériel_informatique|vente liée du logiciel avec le matériel informatique]]<ref>{{Lien web|langue=fr|auteur=Marc Rees|titre=Pourquoi la justice européenne a sanctuarisé la vente liée PC et OS|url=https://web.archive.org/web/20230209112015/https://www.nextinpact.com/article/23625/101268-la-justice-europeenne-sanctuarise-vente-liee-pc-et-os|site=nextinpact.com|éditeur=[[w:Next INpact|Next INpact]]|date=2016-07-09|consulté le=}}.</ref>, mais sans pour autant empêcher [[w:Bill_Gates|Bill Gates]], le principal actionnaire de Microsoft, d'être l'homme le plus riche du monde en 1994. [[Fichier:GNU_and_Tux.svg|alt=Mascotte du projet GNU à gauche et du projet Linux à droite.|vignette|<small>Figure 5. À gauche la mascotte du projet GNU ; à droite celle du projet Linux, appelée Tux.</small>]] Toutefois, pendant que Microsoft renforçait sa position dominante, un nouvel événement majeur allait marquer l’histoire du logiciel libre. Celui-ci fut de nouveau déclenché par un appel à contribution, qui fut cette fois posté le vingt-cinq août 1991 par un jeune étudiant en informatique de 21 ans, appelé [[w:fr:Linus Torvalds|Linus Torvalds]]. Via le système de messagerie [[w:fr:Usenet|Usenet]], sa demande avait été posté dans une liste de diffusion consacrée au système d’exploitation [[w:fr:Minix|Minix]], une sorte d’[[w:UNIX|UNIX]] simplifié et développé dans un but didactique, par le programmeur [[w:fr:Andrew Tanenbaum|Andrew Tanenbaum]]. Loin d’imaginer que cela ferait de lui une nouvelle célébrité dans le monde du Libre<ref>{{Ouvrage|langue=|prénom1=Linus|nom1=Torvalds|prénom2=David|nom2=Diamond|prénom3=Olivier|nom3=Engler|titre=Il était une fois Linux|éditeur=Osman Eyrolles Multimédia|date=2001|isbn=978-2-7464-0321-5|oclc=48059105}}.</ref>, Torvalds entama son message par le paragraphe suivant<ref>{{Ouvrage|langue=|prénom1=Linus|nom1=Torvalds|prénom2=David|nom2=Diamond|titre=Just for fun : the story of an accidental revolutionary|éditeur=HarperBusiness|date=2002|isbn=978-0-06-662073-2|oclc=1049937833}}.</ref> : <blockquote> Je fais un système d’exploitation (gratuit) (juste un hobby, ne sera pas grand et professionnel comme gnu) pour les clones 386 (486) AT. Ce projet est en cours depuis avril et commence à se préparer. J’aimerais avoir un retour sur ce que les gens aiment ou n’aiment pas dans minix, car mon système d’exploitation lui ressemble un peu (même disposition physique du système de fichiers (pour des raisons pratiques) entre autres choses)<ref>Texte original avant sa traduction par www.deepl.com/translator : « ''I'm doing a (free) operating system (just a hobby, won't be big and professional like gnu) for 386(486) AT clones. This has been brewing since april, and is starting to get ready. I'd like any feedback on things people like/dislike in minix, as my OS resembles it somewhat (same physical layout of the file-system (due to practical reasons)among other things) ».''</ref>. </blockquote> Bien qu’il fût présenté comme un passe-temps, le projet qui répondait au nom de « [[w:fr:Noyau Linux|Linux]] », fut rapidement soutenu par des milliers de programmeurs du monde entier, avant de devenir la pièce manquante du projet GNU. En effet, les contributeurs au projet de Stallman n’avaient pas encore terminé l’écriture du code informatique du [[w:noyau_de_système_d'exploitation|noyau]] [[w:GNU Hurd|Hurd]], alors qu'il était censé établir la communication entre la [[w:Suite_logicielle|suite logicielle]] produite par GNU et le [[w:Matériel informatique|matériel informatique]]. C'est donc la fusion des codes produits par les projets GNU et Linux qui permit la création du premier système complet, stable et entièrement libre baptisé [[w:GNU/Linux|GNU/Linux]]. [[Fichier:Debian-OpenLogo.svg|gauche|vignette|<small>Figure 6. Logo du système d’exploitation Debian.</small>|264x264px]] Au départ de ce nouveau système informatique, de nombreuses variantes, que l’on nomme communément « [[w:Distribution_Linux|distributions]] », furent créées par des programmeurs de tous horizons. L’une de celles-ci s’intitule [[w:fr:Debian|Debian]] et tire sa réputation d'être simultanément libre, gratuite, très fiable et produite par une communauté sans lien direct avec une société commerciale<ref>{{Ouvrage|langue=|auteur=|prénom1=Christophe|nom1=Lazaro|titre=La liberte logicielle|passage=|lieu=|éditeur=Academia Bruylant|collection=Anthropologie Prospective|date=2012|pages totales=56|isbn=978-2-87209-861-3|oclc=1104281978}}.</ref>. Quatre qualités qui expliquent pourquoi, Debian est utilisé dans plus de 150 distributions dérivées, mais aussi par de nombreuses entreprises et organisations, à l’image de la [[w:Wikimedia_Foundation|Fondation Wikimédia]], qui l’utilise sur [[metawiki:Wikimedia_servers/fr|ses serveurs]], pour héberger les projets qu'elle supporte<ref>{{Lien web|langue=|auteur=Méta-Wiki|titre=Serveurs Wikimedia|url=https://web.archive.org/web/20251113214321/https://meta.wikimedia.org/wiki/Wikimedia_servers/fr|site=|date=|consulté le=}}.</ref>. Grâce à la naissance des logiciels libres, le mouvement Wikimédia a donc la possibilité de faire tourner ses serveurs informatiques, avec un système d’exploitation fiable, libre et gratuit. Comme son [[w:Code_source|code source⁣⁣]] est ouvert, cela permet aussi à la Fondation Wikimédia de le modifier pour répondre aux besoins spécifiques du mouvement. À la suite de quoi, et selon les règles formulées par la [[w:Communauté_du_logiciel_libre|communauté du logiciel libre]], les modifications faites par la Fondation deviennent à leur tour, gratuitement et librement, utilisables par d’autres personnes ou organismes. À ce premier héritage reçu par le mouvement Wikimédia et toujours en provenance des logiciels libres, s’ajoute une innovation méthodologique. Dans son article ''[[w:La_Cathédrale_et_le_Bazar|La Cathédrale et le Bazar]]''<ref>{{Ouvrage|langue=|auteur=|prénom1=Eric Steven|nom1=Raymond|titre=Cathedral and the bazaar|titre original=Cathedral and the bazaar|traduction titre=La cathédrale et le bazar|passage=|lieu=|éditeur=SnowBall Publishing|date=2010|pages totales=|isbn=978-1-60796-228-1|oclc=833142152|lire en ligne=}}.</ref>, [[w:Eric_Raymond|Eric Raymond]] mobilise en effet le terme « [[w:Cathédrale|cathédrale]] » pour désigner le mode de production des logiciels propriétaires, en opposition au mot « [[w:fr:Bazar|bazar]] », qu'il utilise pour qualifier le mode de développement des logiciels libres. D’un côté, il décrit une organisation pyramidale, rigide et statutairement hiérarchisée, comme on peut la voir souvent au sein des entreprises. Tandis que de l’autre, il parle d’une organisation horizontale, flexible et peu hiérarchisée, qu’il a lui-même expérimentée en adoptant le style de développement de Linus Torvalds, à savoir : « distribuez vite et souvent, déléguez tout ce que vous pouvez déléguer, soyez ouvert jusqu’à la promiscuité »<ref>{{Lien web|langue=|auteur=Eric S. Raymond|traducteur=Sébastien Blondeel|titre=La cathédrale et le bazar|url=https://web.archive.org/web/20200203054716/http://www.linux-france.org/article/these/cathedrale-bazar/cathedrale-bazar-1.html|site=Linux France|lieu=|date=1998|consulté le=}}.</ref>. À l’instar de la métaphore du quartier numérique présentée dans le précédent chapitre, cette manière de décrire les projets open source nous aide donc ici à mieux comprendre ce qui se passe dans le mouvement Wikimédia. D'un côté, on retrouve effectivement cette « ouverture jusqu’à la promiscuité », dans le libre accès accordé aux projets Wikimédia, alors que de l'autre, tout le monde peut participer aux projets Wikimédia, qu'ils soient en ligne ou hors ligne. Ces deux observations corroborent donc l’existence d’un deuxième héritage, en provenance du mouvement du logiciel libre. Néanmoins, il nous reste encore à découvrir un phénomène négligé par Eric Raymond durant ses observations, et qui pourtant, une important, a considérablement influencé l'histoire de la révolution numérique. Il s’agit de l’apparition de la licence libre, de la philosophie qu'elle sous-tend, et du mouvement de la [[w:Culture_libre|culture libre]] dont elle fut à l’origine. {{AutoCat}} mnuwrmp57fy5cncfwxsmmtntga0m2no 765184 765183 2026-04-27T07:20:04Z Lionel Scheepmans 20012 765184 wikitext text/x-wiki <noinclude>{{Le mouvement Wikimédia}}</noinclude> L’un des premiers épisodes de la préhistoire de Wikipédia et du mouvement Wikimédia débuta en septembre 1983, lorsqu’un programmeur du ''[[w:fr:Massachusetts Institute of Technology|Massachusetts Institute of Technology]]'', appelé [[w:fr:Richard Stallman|Richard Stallman]], déposa un message sur la liste de diffusion net.unix-wizards. C’était un appel d’aide pour la création de [[w:Projet GNU|GNU]], un nouveau [[w:fr:Système d'exploitation|système d’exploitation]] qui devait réunir une suite de programmes que tout le monde pourrait utiliser librement sur son ordinateur personnel<ref>{{Ouvrage|langue=|prénom1=Richard M|nom1=Stallman|prénom2=Sam|nom2=Williams|titre=Richard Stallman et la révolution du logiciel libre - Une biographie autorisée|éditeur=Eyrolles|date=2013|oclc=708380925|lire en ligne=https://framabook.org/docs/stallman/framabook6_stallman_v1_gnu-fdl.pdf|consulté le=}}.</ref>. Dans son message transmis via [[w:Arpanet|ARPANET]], le premier réseau informatique à grande échelle qui précéda Internet, Stallman s’exprimait de la sorte<ref>{{Lien web|langue=|auteur=Richard Stallman|titre=Système d'exploitation GNU – Annonce initiale|url=https://web.archive.org/web/20010106133800/http://www.gnu.org:80/gnu/initial-announcement.fr.html|site=GNU|date=3 décembre 2000|consulté le=}}.</ref> : <blockquote> Je considère comme une [[w:Règle d'or|règle d’or]] que si j’apprécie un programme je dois le partager avec d’autres personnes qui l’apprécient. Je ne peux pas en bonne conscience signer un accord de non-divulgation ni un accord de licence de logiciel. Afin de pouvoir continuer à utiliser les ordinateurs sans violer mes principes, j’ai décidé de rassembler une quantité suffisante de logiciels libres, de manière à pouvoir m’en tirer sans aucun logiciel qui ne soit pas libre. </blockquote> Le projet de Stallman, qui reçut le soutien nécessaire à son accomplissement, marqua ainsi le début de l’[[w:Histoire_du_logiciel_libre|histoire du logiciel libre]]. Quant à la quantité d’aide fournie, elle permet de croire que Richard Stallman n’était pas seul à voir l’arrivée des [[w:Logiciel propriétaire|logiciels propriétaires]] d’un mauvais œil. Car pour les membres du projet GNU et du mouvement du logiciel libre en général, un bon programme informatique doit respecter ces quatre libertés fondamentales<ref>{{Lien web|langue=|auteur=Karl Pradène|titre=Qu'est-ce que le logiciel libre ?|url=https://web.archive.org/web/20000511101640/http://www.gnu.org/philosophy/free-sw.fr.html|site=GNU|date=6 mai 2000|consulté le=}}.</ref> : <blockquote> 1. La liberté d’exécuter le programme, pour tous les usages. 2. La liberté d’étudier le fonctionnement du programme, et de l’adapter à vos besoins. 3. La liberté de redistribuer des copies, donc d’aider votre voisin. 4. La liberté d’améliorer le programme, et de publier vos améliorations, pour en faire profiter toute la communauté. </blockquote> [[w:Histoire_du_logiciel_libre|Lors de l'apparition du logiciel libre]], le marché de l’informatique était de fait en pleine mutation. L'habituel partage des codes informatiques entre les rares étudiants ou chercheurs qui bénéficiaient d’un accès à un ordinateur faisait l'objet d'une remise en question. Ce changement faisait notamment suite au [[w:Copyright_Act_(1976)|Copyright Act]] de 1976, une nouvelle loi qui autorisait l'application d'un [[w:Droit_d'auteur|droit d'auteur]] sur le code informatique, et donc qui permettait d'en interdire le partage ou la réutilisation sans autorisation. Des [[w:Clause_de_confidentialité|clauses de confidentialité]] ont ainsi fait leur apparition, pendant que les employés des firmes informatiques étaient nouvellement soumis à des contrats de confidentialité. C'était la fin de l’entraide et de la solidarité pratiquées chez les pionniers de l’informatique. À sa place s'installaient la concurrence et la compétitivité, bien connues dans le système capitaliste marchand. [[Fichier:Commodore64withdisk.jpg|alt=Commodore 64 avec disquette et lecteur|gauche|vignette|<small>Figure 4. Commodore 64 avec disquette et lecteur.</small>|300x300px]] Cette mutation coïncidait avec l’arrivée des premiers ordinateurs de taille réduite. Grâce à l’apparition des premiers [[w:Circuits_intégrés|circuits intégrés⁣⁣]], les premiers exemplaires avaient en effet été créés par l’industrie aérospatiale au début des années 1960. Cependant, il fallut attendre le début des années 1980 pour que le prix d’un ordinateur soit suffisamment bas pour en faire un [[w:Bien_de_grande_consommation|bien de grande consommation]]. C’est ainsi qu’en 1982, le [[w:Commodore 64|Commodore 64]] entrait dans le [[w:Livre_Guinness_des_records|livre Guinness des records]], avec plus de 17 millions d’exemplaires vendus dans le monde<ref>{{Lien web|langue=|auteur=Brandon Griggs|titre=The Commodore 64, that '80 s computer icon, lives again|url=https://web.archive.org/web/20200706161515/http://edition.cnn.com/2011/TECH/gaming.gadgets/05/09/commodore.64.reborn|site=CNN|date=May 9, 2011|consulté le=}}.</ref>. Juste avant cela, en 1981, l’''[[w:fr:IBM PC|IBM Personal Computer]]'' avait déjà fait son apparition, en proposant une [[w:Architecture_(informatique)|architecture]] ouverte qui allait servir de modèle pour toute une gamme d’ordinateurs que l’on désigne toujours aujourd’hui par l’acronyme « PC ». Pour faire fonctionner ses nouveaux modèles d'ordinateurs, la société IBM avait confié à l’entreprise [[w:Microsoft|Microsoft]], créée en 1975, la mission de les équiper d’un système d’exploitation. Le contrat signé entre les deux firmes fut une véritable aubaine pour le fournisseur des programmes informatiques. Car sans s'en apercevoir, et sans jamais anticiper que son matériel serait cloné à grande échelle, celle-ci avait en effet permis à Microsoft d'établir un monopole dans la vente de logiciels. Cela fut condamné pour [[w:Abus_de_position_dominante|abus de position dominante]]<ref name="Combier_2018_01_24">{{Lien web|langue=fr|auteur=Étienne Combier|titre=Abus de position dominante : les plus grosses amendes de la Commission européenne|url=https://web.archive.org/web/20230511110018/https://www.lesechos.fr/2018/01/abus-de-position-dominante-les-plus-grosses-amendes-de-la-commission-europeenne-982719|périodique=[[w:Les Échos|Les Échos]]|date=2018-01-24|consulté le=}}.</ref> et [[w:Vente_liée_de_logiciels_avec_du_matériel_informatique|vente liée du logiciel avec le matériel informatique]]<ref>{{Lien web|langue=fr|auteur=Marc Rees|titre=Pourquoi la justice européenne a sanctuarisé la vente liée PC et OS|url=https://web.archive.org/web/20230209112015/https://www.nextinpact.com/article/23625/101268-la-justice-europeenne-sanctuarise-vente-liee-pc-et-os|site=nextinpact.com|éditeur=[[w:Next INpact|Next INpact]]|date=2016-07-09|consulté le=}}.</ref>, mais sans pour autant empêcher [[w:Bill_Gates|Bill Gates]], le principal actionnaire de Microsoft, d'être l'homme le plus riche du monde en 1994. [[Fichier:GNU_and_Tux.svg|alt=Mascotte du projet GNU à gauche et du projet Linux à droite.|vignette|<small>Figure 5. À gauche la mascotte du projet GNU ; à droite celle du projet Linux, appelée Tux.</small>]] Toutefois, pendant que Microsoft renforçait sa position dominante, un nouvel événement majeur allait marquer l’histoire du logiciel libre. Celui-ci fut de nouveau déclenché par un appel à contribution, qui fut cette fois posté le vingt-cinq août 1991 par un jeune étudiant en informatique de 21 ans, appelé [[w:fr:Linus Torvalds|Linus Torvalds]]. Via le système de messagerie [[w:fr:Usenet|Usenet]], sa demande avait été posté dans une liste de diffusion consacrée au système d’exploitation [[w:fr:Minix|Minix]], une sorte d’[[w:UNIX|UNIX]] simplifié et développé dans un but didactique, par le programmeur [[w:fr:Andrew Tanenbaum|Andrew Tanenbaum]]. Loin d’imaginer que cela ferait de lui une nouvelle célébrité dans le monde du Libre<ref>{{Ouvrage|langue=|prénom1=Linus|nom1=Torvalds|prénom2=David|nom2=Diamond|prénom3=Olivier|nom3=Engler|titre=Il était une fois Linux|éditeur=Osman Eyrolles Multimédia|date=2001|isbn=978-2-7464-0321-5|oclc=48059105}}.</ref>, Torvalds entama son message par le paragraphe suivant<ref>{{Ouvrage|langue=|prénom1=Linus|nom1=Torvalds|prénom2=David|nom2=Diamond|titre=Just for fun : the story of an accidental revolutionary|éditeur=HarperBusiness|date=2002|isbn=978-0-06-662073-2|oclc=1049937833}}.</ref> : <blockquote> Je fais un système d’exploitation (gratuit) (juste un hobby, ne sera pas grand et professionnel comme gnu) pour les clones 386 (486) AT. Ce projet est en cours depuis avril et commence à se préparer. J’aimerais avoir un retour sur ce que les gens aiment ou n’aiment pas dans minix, car mon système d’exploitation lui ressemble un peu (même disposition physique du système de fichiers (pour des raisons pratiques) entre autres choses)<ref>Texte original avant sa traduction par www.deepl.com/translator : « ''I'm doing a (free) operating system (just a hobby, won't be big and professional like gnu) for 386(486) AT clones. This has been brewing since april, and is starting to get ready. I'd like any feedback on things people like/dislike in minix, as my OS resembles it somewhat (same physical layout of the file-system (due to practical reasons)among other things) ».''</ref>. </blockquote> Bien qu’il fût présenté comme un passe-temps, le projet qui répondait au nom de « [[w:fr:Noyau Linux|Linux]] », fut rapidement soutenu par des milliers de programmeurs du monde entier, avant de devenir la pièce manquante du projet GNU. En effet, les contributeurs au projet de Stallman n’avaient pas encore terminé l’écriture du code informatique du [[w:noyau_de_système_d'exploitation|noyau]] [[w:GNU Hurd|Hurd]], alors qu'il était censé établir la communication entre la [[w:Suite_logicielle|suite logicielle]] produite par GNU et le [[w:Matériel informatique|matériel informatique]]. C'est donc la fusion des codes produits par les projets GNU et Linux qui permit la création du premier système complet, stable et entièrement libre baptisé [[w:GNU/Linux|GNU/Linux]]. [[Fichier:Debian-OpenLogo.svg|gauche|vignette|<small>Figure 6. Logo du système d’exploitation Debian.</small>|264x264px]] Au départ de ce nouveau système informatique, de nombreuses variantes, que l’on nomme communément « [[w:Distribution_Linux|distributions]] », furent créées par des programmeurs de tous horizons. L’une de celles-ci s’intitule [[w:fr:Debian|Debian]] et tire sa réputation d'être simultanément libre, gratuite, très fiable et produite par une communauté sans lien direct avec une société commerciale<ref>{{Ouvrage|langue=|auteur=|prénom1=Christophe|nom1=Lazaro|titre=La liberte logicielle|passage=|lieu=|éditeur=Academia Bruylant|collection=Anthropologie Prospective|date=2012|pages totales=56|isbn=978-2-87209-861-3|oclc=1104281978}}.</ref>. Quatre qualités qui expliquent pourquoi, Debian est utilisé dans plus de 150 distributions dérivées, mais aussi par de nombreuses entreprises et organisations, à l’image de la [[w:Wikimedia_Foundation|Fondation Wikimédia]], qui l’utilise sur [[metawiki:Wikimedia_servers/fr|ses serveurs]], pour héberger les projets qu'elle supporte<ref>{{Lien web|langue=|auteur=Méta-Wiki|titre=Serveurs Wikimedia|url=https://web.archive.org/web/20251113214321/https://meta.wikimedia.org/wiki/Wikimedia_servers/fr|site=|date=|consulté le=}}.</ref>. Grâce à la naissance des logiciels libres, le mouvement Wikimédia a donc la possibilité de faire tourner ses serveurs informatiques, avec un système d’exploitation fiable, libre et gratuit. Comme son [[w:Code_source|code source⁣⁣]] est ouvert, cela permet aussi à la Fondation Wikimédia de le modifier pour répondre aux besoins spécifiques du mouvement. À la suite de quoi, et selon les règles formulées par la [[w:Communauté_du_logiciel_libre|communauté du logiciel libre]], les modifications faites par la Fondation deviennent à leur tour, gratuitement et librement, utilisables par d’autres personnes ou organismes. À ce premier héritage reçu par le mouvement Wikimédia et toujours en provenance des logiciels libres, s’ajoute une innovation méthodologique. Dans son article ''[[w:La_Cathédrale_et_le_Bazar|La Cathédrale et le Bazar]]''<ref>{{Ouvrage|langue=|auteur=|prénom1=Eric Steven|nom1=Raymond|titre=Cathedral and the bazaar|titre original=Cathedral and the bazaar|traduction titre=La cathédrale et le bazar|passage=|lieu=|éditeur=SnowBall Publishing|date=2010|pages totales=|isbn=978-1-60796-228-1|oclc=833142152|lire en ligne=}}.</ref>, [[w:Eric_Raymond|Eric Raymond]] mobilise en effet le terme « [[w:Cathédrale|cathédrale]] » pour désigner le mode de production des logiciels propriétaires, en opposition au mot « [[w:fr:Bazar|bazar]] », qu'il utilise pour qualifier le mode de développement des logiciels libres. D’un côté, il décrit une organisation pyramidale, rigide et statutairement hiérarchisée, comme on peut la voir souvent au sein des entreprises. Tandis que de l’autre, il parle d’une organisation horizontale, flexible et peu hiérarchisée, qu’il a lui-même expérimentée en adoptant le style de développement de Linus Torvalds, à savoir : « distribuez vite et souvent, déléguez tout ce que vous pouvez déléguer, soyez ouvert jusqu’à la promiscuité »<ref>{{Lien web|langue=|auteur=Eric S. Raymond|traducteur=Sébastien Blondeel|titre=La cathédrale et le bazar|url=https://web.archive.org/web/20200203054716/http://www.linux-france.org/article/these/cathedrale-bazar/cathedrale-bazar-1.html|site=Linux France|lieu=|date=1998|consulté le=}}.</ref>. À l’instar de la métaphore du quartier numérique présentée dans le précédent chapitre, cette manière de décrire les projets open source nous aide donc ici à mieux comprendre ce qui se passe dans le mouvement Wikimédia. D'un côté, on retrouve effectivement cette « ouverture jusqu’à la promiscuité », dans le libre accès accordé aux projets Wikimédia, alors que de l'autre, tout le monde peut participer aux projets Wikimédia, qu'ils soient en ligne ou hors ligne. Ces deux observations corroborent donc l’existence d’un deuxième héritage en provenance du mouvement du logiciel libre. Néanmoins, il nous reste encore à découvrir un phénomène négligé par Eric Raymond durant ses observations, et qui pourtant, une important, a considérablement influencé l'histoire de la révolution numérique. Il s’agit de l’apparition de la licence libre, de la philosophie qu'elle sous-tend, et du mouvement de la [[w:Culture_libre|culture libre]], dont elle fut à l’origine. {{AutoCat}} i77g71jojr5hfx9h9tbnukxtb18ykxt Le mouvement Wikimédia/Le réseau Internet 0 79340 765185 764396 2026-04-27T07:27:00Z Lionel Scheepmans 20012 765185 wikitext text/x-wiki <noinclude>{{Le mouvement Wikimédia}}</noinclude> L’[[w:fr : Histoire d'Internet|histoire du réseau Internet]] constitue un nouvel épisode captivant de la révolution numérique, sans lequel le mouvement Wikimédia n’aurait jamais pu émerger. D’un point de vue purement technique, ce réseau informatique a été mis au point dans les années 1970, avant l’adoption généralisée du protocole [[w:fr : Suite des protocoles Internet|TCP/IP]], toujours en usage à ce jour. Ce dernier fut inventé par [[w:Vint_Cerf|Vint Cerf]] et [[w:fr : Bob Kahn|Robert Elliot Kahn]], quand ils travaillaient pour la ''[[w:fr : Defense Advanced Research Projects Agency|Defense Advanced Research Projects Agency]]'', rattachée au département de la Défense américaine<ref name="Chemla">{{Ouvrage|langue=|auteur=Laurent Chemla|prénom1=Djilali|nom1=Benamrane|nom2=Biens publics à l'échelle mondiale|nom3=Coopération solidarité développement aux PTT|titre=Les télécommunications, entre bien public et marchandise|passage=73 & 63 (par ordre de citation)|lieu=Une histoire d'Internet|éditeur=ECLM (Charles Leopold Mayer)|date=2005|pages totales=|isbn=978-2-84377-111-8|oclc=833154536|lire en ligne=|consulté le=}}.</ref>. L'une des premières présentations de leur projet fut, entre autres, réalisée lors d’une conférence organisée par l’[[w:fr : International Network Working Group|International Network Working Group]], une instance créée pour assurer la gouvernance mondiale du réseau informatique. Sur base de ces informations, on peut penser qu’Internet a été créé par des militaires. Cependant, ''[[w:Une_contre-histoire_de_l'Internet|Une contre-histoire de l’Internet]]''<ref>{{Ouvrage|éditeur=Premieres Lignes Television|titre=Une contre-histoire de l'Internet|année=2013|auteur=[[w:Sylvain Bergère|]]}}</ref>, nous révèle que les créateurs et les premiers utilisateurs d’[[w:ARPANET|ARPANET]], considéré comme l’ancêtre d’Internet, étaient davantage des étudiants [[w:Hippies|hippies]] et amateurs de [[w:LSD|LSD]], que des militaires bien drillés. D’ailleurs, avant la standardisation du protocole TCP/IP, ARPANET fonctionnait depuis plus d’un an avec un autre protocole de transition intitulé [[w:Network_Control_Program_(Arpanet)|''Network Control Program'']]. Or, celui-ci avait été mis au point, en février 1969, par le [[w:fr : Network Working Group|''Network Working Group'']], un groupe informel d’étudiants rassemblés autour de [[w:Steve_Crocker|Steve Crocker]], lorsqu’il ne détenait encore qu’une simple licence. [[Fichier:Internet_map_1024.jpg|alt=Nuage filandreux de lignes multicolores|vignette|<small>Figure 10. Carte partielle d’Internet, créée sur base des données d’opte.org en date du 15 juin 2005.</small>|gauche|300x300px]] Bien que rarement cité dans l’histoire d’Internet, ce groupe a pourtant mis en place la procédure RFC, pour ''[[w:Request_for_comments|Request For Comments]],'' reconnue comme « l’un des symboles forts de la "culture technique" de l’Internet, marquée par l’égalitarisme, l’autogestion et la recherche collective de l’efficience »<ref name="Serres">{{Article|auteur=|prénom1=Alexandre|nom1=Serres|prénom2=Christian|nom2=Le Moënne|prénom3=Jean-Max|nom3=Noyer|nom4=|titre=Aux sources d'internet : l'émergence d'Arpanet : exploration du processus d'émergence d'une infrastructure informationnelle : description des trajectoires des acteurs et actants, des filières et des réseaux constitutifs de la naissance d'Arpanet : problèmes critiques et épistémologiques posés par l'histoire des innovations|périodique=Thèse de doctorat|éditeur=Université Rennes 2|date=2000|issn=|lire en ligne=https://tel.archives-ouvertes.fr/tel-00312005/document|pages=481 & 488 (par ordre de citation)}}.</ref>. Soit trois principes et une procédure, qui aujourd’hui encore sont appliqués sur le site Méta-Wiki, dans lequel s'organise la gestion communautaire du mouvement Wikimédia. Cela alors qu'au sein des projets pédagogiques, d’autres processus similaires de recherche de consensus ont fait leur apparition. Il faut savoir ensuite que les liens entre ARPANET et l’armée ont disparu avec l’apparition du [[w:en : MILNET|MILNET]], un réseau entièrement dédié aux activités militaires, qui a ensuite été rebaptisé [[w:en:NIPRNet|NIPRNet]], pour Non-classified Internet Protocol Router Network, en 1990. Après une séparation définitive en 1983, précisément l’année où [[w:Richard_Stallman|Richard Stallman]] postait sa demande d’aide pour le [[w:Projet_GNU|projet GNU]], le réseau ARPANET resta uniquement dédié à la recherche et au développement<ref>{{Ouvrage|langue=|auteur=|prénom1=Stephen|nom1=Denneti|titre=ARPANET Information Brochure|passage=4|lieu=|éditeur=Defense Communications Agency|date=1978|pages totales=46|isbn=|oclc=476024876|lire en ligne=https://web.archive.org/web/20200710174908/https://apps.dtic.mil/dtic/tr/fulltext/u2/a164353.pdf}}.</ref>. À cette époque, le réseau ne comprenait pas plus de 600 machines connectées<ref>{{Ouvrage|langue=|auteur=|prénom1=Solange|nom1=Ghernaouti-Hélie|prénom2=Arnaud|nom2=Dufour|titre=Internet|passage=|lieu=|éditeur=Presses universitaires de France|date=2012|pages totales=|isbn=978-2-13-058548-0|oclc=795497443|lire en ligne=|consulté le=}}.</ref>, ce qui n'a donc rien de comparable avec ce vaste réseau informatique mondial que nous connaissons aujourd’hui, et qui fut fortement développé au cours des années 1990. Pour en assurer l’entretien technique, une [[w:Organisation_non_gouvernementale|organisation non gouvernementale]], a été créée en 1992, sous l'appellation d’''[[w:fr : Internet Society|Internet Society]]''. Celle-ci devait aussi veiller au respect des valeurs fondamentales liées au bon fonctionnement du réseau<ref>{{Lien web|langue=|auteur=Étienne Combier|titre=Les leçons de l’Internet Society pour sauver la Toile|url=https://web.archive.org/web/20201024101959/https://www.lesechos.fr/2017/09/les-lecons-de-linternet-society-pour-sauver-la-toile-182263|site=Les Echos|éditeur=|date=2017-09-19|consulté le=}}.</ref>. Car avant d'atteindre des milliards d’appareils connectés en réseau, il a d’abord fallu réglementer les nombreuses [[w:Dorsale Internet|dorsales internet]] intercontinentales, sans lesquelles la transmission du protocole TCP/IP partout dans le monde n'aurait pas été possible. Pour en revenir à l’état d’esprit des créateurs d'Internet, un article intitulé ''Quarante ans après : mais qui donc créa l’internet ?'' apporte un éclairage particulièrement intéressant au sujet des liens que l'on peut établir entre le mouvement Wikimédia et l'[[w:Histoire_d'Internet|histoire d'Internet]]. Dans son témoignage, [[w:fr:Michel Elie|Michel Elie]], cet ingénieur en informatique, membre du ''Network Working Group'' cité précédemment, et responsable de l’[[w:fr : Observatoire des usages de l'Internet|Observatoire des Usages de l’Internet]], nous explique effectivement ceci. <blockquote> Le succès de l’internet, nous le devons aux bons choix initiaux et à la dynamique qui en est résultée : la collaboration de dizaines de milliers d’étudiants, ou de bénévoles apportant leur expertise, tels par exemple ces centaines de personnes qui enrichissent continuellement des encyclopédies en ligne telles que Wikipédia.''<ref>{{Lien web|langue=|auteur=Michel Elie|titre=Quarante ans après : mais qui donc créa l'internet ?|url=https://web.archive.org/web/20200131180536/https://vecam.org/archives/article1123.html|site=Vecam|lieu=|date=2009|consulté le=}}.</ref>'' </blockquote> Au courant des années 1990, le milieu informatique universitaire semblait donc toujours fortement imprégné des idéaux de la [[w:Contre-culture des années 1960|contre-culture des années 1960]], produit par les [[w:Baby_boomer|''baby boomers'']] dans le contexte de la [[w:Guerre_du_Vietnam|guerre du Vietnam]]. Afin d'illustrer les idées véhiculées à cette époque, voici un paragraphe extrait d'un ouvrage publié en 1970 et intitulé ''Vers une contre-culture : Réflexions sur la société technocratique et l’opposition de la jeunesse''<ref>{{Ouvrage|langue=|prénom1=Theodore|nom1=Roszak|prénom2=Claude|nom2=Elsen|titre=Vers une contre-culture. Réflexions sur la société technocratique et l'opposition de la jeunesse|passage=266-267|lieu=Paris|éditeur=Stock|date=1970|pages totales=318|isbn=978-2-234-01282-0|oclc=36236326}}.</ref>. Dans celui-ci, [[w:fr : Theodore Roszak|Théodore Roszak]] explique que : <blockquote> Le projet essentiel de notre contre-culture : proclamer un nouveau ciel et une nouvelle terre, si vastes, si merveilleux que les prétentions démesurées de la technique soient réduites à n’occuper dans la vie humaine qu’une place inférieure et marginale. Créer et répandre une telle conception de la vie n’implique rien de moins que l’acceptation de nous ouvrir à l’imagination visionnaire. Nous devons être prêts à soutenir ce qu’affirment des personnes telles que [[w:fr : William Blake|Blake]], à savoir que certains yeux ne voient pas le monde comme le voient le regard banal ou l’œil scientifique, mais le voient transformé, dans une lumière éclatante et, ce faisant, le voient tel qu’il est vraiment. </blockquote> À la suite de cette lecture, il peut sembler paradoxal de penser qu’une contre-culture, voyant la technique comme « inférieure » et assimilant la science au « banal », puisse avoir un lien avec le milieu scientifique universitaire qui fut à l'origine d'Internet. Cependant, une réponse à cette énigme fut apportée par [[w:Fred_Turner_(professeur)|Fred Turner]], par la publication de son livre intitulé : « ''Aux sources de l’utopie numérique : De la contre-culture à la cyberculture, [[w:Stewart_Brand|Stewart Brand]], un homme d’influence »''<ref>{{Ouvrage|langue=fr|prénom1=Fred|nom1=Turner|titre=Aux sources de l'utopie numérique: De la contre-culture à la cyberculture, Stewart Brand, un homme d'influence|éditeur=C & F Éditions|date=2021-07-07|isbn=978-2-37662-032-7}}.</ref>. Grâce à cet ouvrage, on découvre en effet que le mouvement Hippie utilisera tout ce qui était à sa disposition à l’époque pour parvenir à ses fins : LSD, spiritualités alternatives, mais également, objets technologiques les plus en pointe. Cela grâce notamment à l’influence de Steward Brand, le créateur d'un catalogue interactif, qui peut être considéré comme l'ancêtre analogique des groupes de discussions numériques apparus des années plus tard<ref>{{Lien web|auteur=Guillaume de Lamérie|titre=Aux sources de l’utopie numérique, de la contre-culture à la cyberculture|url=https://web.archive.org/web/20211021183032/https://www.afis.org/Aux-sources-de-l-utopie-numerique|site=Association française pour l’Information Scientifique|éditeur=|date=18 septembre 2013|consulté le=}}.</ref>. Comme autre indication, il y a ensuite les propos tenus en 1992, lors d’une plénière de la 24ᵉ réunion du groupe de travail sur l’ingénierie Internet, par [[w:fr : David D. Clark|David D. Clark]], un autre pionnier d’Internet. Durant cette rencontre, ce chef de projet prononça des paroles<ref>{{Article|prénom1=Andrew L.|nom1=Russell|titre='Rough Consensus and Running Code' and the Internet-OSI Standards War|périodique=IEEE Annals Hist. Comput. IEEE Annals of the History of Computing|volume=28|numéro=3|date=2006|issn=1058-6180|pages=48–61}}.</ref> restées dans les annales. « Nous récusons rois, présidents et votes. Nous croyons au consensus et aux programmes qui tournent »<ref>Texte original avant sa traduction par www.deepl.com/translator : « ''We reject: kings, presidents and voting. We believe: in rough consensus and running code »''</ref>. Deux phrases seulement, mais qui, dans le cadre du milieu informatique, permettent de croire que le mépris de la contre-culture envers la technique et la science, s'est transformé en un refus d’autorité et une recherche de consensus. Dans tous les cas, le développement du réseau Internet ne s'est pas fait sans conflits idéologiques importants. On peut d'ailleurs se demander aujourd'hui à quoi ressemblerait Internet s'il n'avait jamais été [[w:en:Commercialization_of_the_Internet|commercialisé]]<ref>{{Ouvrage|langue=en|prénom1=Shane|nom1=Greenstein|titre=How the Internet Became Commercial: Innovation, Privatization, and the Birth of a New Network|passage=79|éditeur=Princeton University Press|date=2017-09-26|isbn=978-0-691-17839-4|consulté le=2025-12-27}}</ref>. Cela s'est passé en novembre 1994, lorsque l’association sans but lucratif ''[[w:en:Advanced_Network_and_Services|Advanced Network and Services,]] chargée de g''érer les accès à Internet, a fait le choix de vendre ses activités. Cette décision faisait suite à un appel à des fonds privés pour financer d'importants changements dans l'infrastructure du réseau. Profitant de l'occasion, la société commerciale ''[[w:AOL|America Online]]'' a ainsi repris à son compte la gestion des connexions à Internet, après avoir effectué un versement de 35 millions de dollars<ref>{{Lien web|titre=ANS sold to America On-line|url=https://web.archive.org/web/20110927083123/http://www.merit.edu/mail.archives/mjts/1994-11/msg00023.html|site=www.merit.edu|auteur=Jeff.Ogden}}</ref>. Trente ans plus tard, Internet est devenu ce réseau que nous expérimentons aujourd'hui, à savoir, un réseau dominé par des sociétés privées les plus riches au monde. Dans ce contexte et parmi les 100 [[w:_Liste_des_sites_web_les_plus_visités|sites web les plus visités au monde]], seul le [[w:Nom_de_domaine|nom de domaine]] Wikipédia appartient à une organisation non lucrative<ref>{{Lien web|titre=Top 100 Most Visited Websites Worldwide (August 2025)|url=https://web.archive.org/web/20250923022332/https://www.similarweb.com/blog/research/market-research/most-visited-websites/|auteur=Similarweb}}</ref>. Cela explique donc pourquoi le mouvement Wikimédia, via son encyclopédie et la fondation qui l'héberge, représente à ce jour, et dans l'espace web, l'expression la plus visible de la philosophie des pionniers d’Internet. Plus qu'un héritage, cette situation peut être vue comme une mission perpétuée au sein d'un espace envahi par une culture marchande et capitaliste. C'est là une information importante qu'il faut retenir au sujet du mouvement Wikimédia. Elle ne clôture pas pour autant tout ce qu'il faut savoir au sujet des évènements qui ont permis la création d’une encyclopédie mondiale, libre et collaborative. En revanche, elle nous invite à découvrir l'histoire du World Wide Web, un espace numérique sans lequel la création de Wikipédia n'aurait jamais été possible.{{AutoCat}} lqav7phi07ojda7aez0qt6qhou6h4op 765186 765185 2026-04-27T07:32:46Z Lionel Scheepmans 20012 765186 wikitext text/x-wiki <noinclude>{{Le mouvement Wikimédia}}</noinclude> L’[[w:fr : Histoire d'Internet|histoire du réseau Internet]] constitue un nouvel épisode captivant de la révolution numérique, sans lequel le mouvement Wikimédia n’aurait jamais pu émerger. D’un point de vue purement technique, ce réseau informatique a été mis au point dans les années 1970, avant l’adoption généralisée du protocole [[w:fr : Suite des protocoles Internet|TCP/IP]], toujours en usage à ce jour. Ce dernier fut inventé par [[w:Vint_Cerf|Vint Cerf]] et [[w:fr : Bob Kahn|Robert Elliot Kahn]], quand ils travaillaient pour la ''[[w:fr : Defense Advanced Research Projects Agency|Defense Advanced Research Projects Agency]]'', rattachée au département de la Défense américaine<ref name="Chemla">{{Ouvrage|langue=|auteur=Laurent Chemla|prénom1=Djilali|nom1=Benamrane|nom2=Biens publics à l'échelle mondiale|nom3=Coopération solidarité développement aux PTT|titre=Les télécommunications, entre bien public et marchandise|passage=73 & 63 (par ordre de citation)|lieu=Une histoire d'Internet|éditeur=ECLM (Charles Leopold Mayer)|date=2005|pages totales=|isbn=978-2-84377-111-8|oclc=833154536|lire en ligne=|consulté le=}}.</ref>. L'une des premières présentations de leur projet fut, entre autres, réalisée lors d’une conférence organisée par l’[[w:fr : International Network Working Group|International Network Working Group]], une instance créée pour assurer la gouvernance mondiale du réseau informatique. Sur base de ces informations, on peut penser qu’Internet a été créé par des militaires. Cependant, ''[[w:Une_contre-histoire_de_l'Internet|Une contre-histoire de l’Internet]]''<ref>{{Ouvrage|éditeur=Premieres Lignes Television|titre=Une contre-histoire de l'Internet|année=2013|auteur=[[w:Sylvain Bergère|]]}}</ref>, nous révèle que les créateurs et les premiers utilisateurs d’[[w:ARPANET|ARPANET]], considéré comme l’ancêtre d’Internet, étaient davantage des étudiants [[w:Hippies|hippies]] et amateurs de [[w:LSD|LSD]], que des militaires bien drillés. D’ailleurs, avant la standardisation du protocole TCP/IP, ARPANET fonctionnait depuis plus d’un an avec un autre protocole de transition intitulé [[w:Network_Control_Program_(Arpanet)|''Network Control Program'']]. Or, celui-ci avait été mis au point, en février 1969, par le [[w:fr : Network Working Group|''Network Working Group'']], un groupe informel d’étudiants rassemblés autour de [[w:Steve_Crocker|Steve Crocker]], lorsqu’il ne détenait encore qu’une simple licence. [[Fichier:Internet_map_1024.jpg|alt=Nuage filandreux de lignes multicolores|vignette|<small>Figure 10. Carte partielle d’Internet, créée sur base des données d’opte.org en date du 15 juin 2005.</small>|gauche|300x300px]] Bien que rarement cité dans l’histoire d’Internet, ce groupe a pourtant mis en place la procédure RFC, pour ''[[w:Request_for_comments|Request For Comments]],'' reconnue comme « l’un des symboles forts de la "culture technique" de l’Internet, marquée par l’égalitarisme, l’autogestion et la recherche collective de l’efficience »<ref name="Serres">{{Article|auteur=|prénom1=Alexandre|nom1=Serres|prénom2=Christian|nom2=Le Moënne|prénom3=Jean-Max|nom3=Noyer|nom4=|titre=Aux sources d'internet : l'émergence d'Arpanet : exploration du processus d'émergence d'une infrastructure informationnelle : description des trajectoires des acteurs et actants, des filières et des réseaux constitutifs de la naissance d'Arpanet : problèmes critiques et épistémologiques posés par l'histoire des innovations|périodique=Thèse de doctorat|éditeur=Université Rennes 2|date=2000|issn=|lire en ligne=https://tel.archives-ouvertes.fr/tel-00312005/document|pages=481 & 488 (par ordre de citation)}}.</ref>. Soit trois principes et une procédure, qui aujourd’hui encore sont appliqués sur le site Méta-Wiki, dans lequel s'organise la gestion communautaire du mouvement Wikimédia. Cela alors qu'au sein des projets pédagogiques, d’autres processus similaires de recherche de consensus ont fait leur apparition. Il faut ensuite savoir que les liens entre ARPANET et l’armée ont disparu avec l’apparition du [[w:en : MILNET|MILNET]], un réseau entièrement dédié aux activités militaires, rebaptisé [[w:en:NIPRNet|NIPRNet]], pour Non-classified Internet Protocol Router Network, en 1990. Après une séparation définitive en 1983, précisément l’année où [[w:Richard_Stallman|Richard Stallman]] postait sa demande d’aide pour le [[w:Projet_GNU|projet GNU]], le réseau ARPANET resta uniquement dédié à la recherche et au développement<ref>{{Ouvrage|langue=|auteur=|prénom1=Stephen|nom1=Denneti|titre=ARPANET Information Brochure|passage=4|lieu=|éditeur=Defense Communications Agency|date=1978|pages totales=46|isbn=|oclc=476024876|lire en ligne=https://web.archive.org/web/20200710174908/https://apps.dtic.mil/dtic/tr/fulltext/u2/a164353.pdf}}.</ref>. À cette époque, le réseau ne comprenait pas plus de 600 machines connectées<ref>{{Ouvrage|langue=|auteur=|prénom1=Solange|nom1=Ghernaouti-Hélie|prénom2=Arnaud|nom2=Dufour|titre=Internet|passage=|lieu=|éditeur=Presses universitaires de France|date=2012|pages totales=|isbn=978-2-13-058548-0|oclc=795497443|lire en ligne=|consulté le=}}.</ref>, ce qui n'a donc rien de comparable avec ce vaste réseau informatique mondial que nous connaissons aujourd’hui, et qui fut fortement développé au cours des années 1990. Pour en assurer l’entretien technique, une [[w:Organisation_non_gouvernementale|organisation non gouvernementale]], a été créée en 1992, sous l'appellation d’''[[w:fr : Internet Society|Internet Society]]''. Celle-ci devait aussi veiller au respect des valeurs fondamentales liées au bon fonctionnement du réseau<ref>{{Lien web|langue=|auteur=Étienne Combier|titre=Les leçons de l’Internet Society pour sauver la Toile|url=https://web.archive.org/web/20201024101959/https://www.lesechos.fr/2017/09/les-lecons-de-linternet-society-pour-sauver-la-toile-182263|site=Les Echos|éditeur=|date=2017-09-19|consulté le=}}.</ref>. Car avant d'atteindre des milliards d’appareils connectés en réseau, il a d’abord fallu réglementer les nombreuses [[w:Dorsale Internet|dorsales internet]] intercontinentales, sans lesquelles la transmission du protocole TCP/IP partout dans le monde n'aurait pas été possible. Pour en revenir à l’état d’esprit des créateurs d'Internet, un article intitulé ''Quarante ans après : mais qui donc créa l’internet ?'' apporte un éclairage particulièrement intéressant au sujet des liens que l'on peut établir entre le mouvement Wikimédia et l'[[w:Histoire_d'Internet|histoire d'Internet]]. Dans son témoignage, [[w:fr:Michel Elie|Michel Elie]], cet ingénieur en informatique, membre du ''Network Working Group'' cité précédemment, et responsable de l’[[w:fr : Observatoire des usages de l'Internet|Observatoire des Usages de l’Internet]], nous explique effectivement ceci. <blockquote> Le succès de l’internet, nous le devons aux bons choix initiaux et à la dynamique qui en est résultée : la collaboration de dizaines de milliers d’étudiants, ou de bénévoles apportant leur expertise, tels par exemple ces centaines de personnes qui enrichissent continuellement des encyclopédies en ligne telles que Wikipédia.''<ref>{{Lien web|langue=|auteur=Michel Elie|titre=Quarante ans après : mais qui donc créa l'internet ?|url=https://web.archive.org/web/20200131180536/https://vecam.org/archives/article1123.html|site=Vecam|lieu=|date=2009|consulté le=}}.</ref>'' </blockquote> Au courant des années 1990, le milieu informatique universitaire semblait donc toujours fortement imprégné des idéaux de la [[w:Contre-culture des années 1960|contre-culture des années 1960]], produit par les [[w:Baby_boomer|''baby boomers'']] dans le contexte de la [[w:Guerre_du_Vietnam|guerre du Vietnam]]. Afin d'illustrer les idées véhiculées à cette époque, voici un paragraphe extrait d'un ouvrage publié en 1970 et intitulé ''Vers une contre-culture : Réflexions sur la société technocratique et l’opposition de la jeunesse''<ref>{{Ouvrage|langue=|prénom1=Theodore|nom1=Roszak|prénom2=Claude|nom2=Elsen|titre=Vers une contre-culture. Réflexions sur la société technocratique et l'opposition de la jeunesse|passage=266-267|lieu=Paris|éditeur=Stock|date=1970|pages totales=318|isbn=978-2-234-01282-0|oclc=36236326}}.</ref>. Dans celui-ci, [[w:fr : Theodore Roszak|Théodore Roszak]] explique que : <blockquote> Le projet essentiel de notre contre-culture : proclamer un nouveau ciel et une nouvelle terre, si vastes, si merveilleux que les prétentions démesurées de la technique soient réduites à n’occuper dans la vie humaine qu’une place inférieure et marginale. Créer et répandre une telle conception de la vie n’implique rien de moins que l’acceptation de nous ouvrir à l’imagination visionnaire. Nous devons être prêts à soutenir ce qu’affirment des personnes telles que [[w:fr : William Blake|Blake]], à savoir que certains yeux ne voient pas le monde comme le voient le regard banal ou l’œil scientifique, mais le voient transformé, dans une lumière éclatante et, ce faisant, le voient tel qu’il est vraiment. </blockquote> À la suite de cette lecture, il peut sembler paradoxal de penser qu’une contre-culture, voyant la technique comme « inférieure » et assimilant la science au « banal », puisse avoir un lien avec le milieu scientifique universitaire qui fut à l'origine d'Internet. Cependant, une réponse à cette énigme fut apportée par [[w:Fred_Turner_(professeur)|Fred Turner]], par la publication de son livre intitulé : « ''Aux sources de l’utopie numérique : De la contre-culture à la cyberculture, [[w:Stewart_Brand|Stewart Brand]], un homme d’influence »''<ref>{{Ouvrage|langue=fr|prénom1=Fred|nom1=Turner|titre=Aux sources de l'utopie numérique: De la contre-culture à la cyberculture, Stewart Brand, un homme d'influence|éditeur=C & F Éditions|date=2021-07-07|isbn=978-2-37662-032-7}}.</ref>. Grâce à cet ouvrage, on découvre en effet que le mouvement Hippie utilisera tout ce qui était à sa disposition à l’époque pour parvenir à ses fins : LSD, spiritualités alternatives, mais également, objets technologiques les plus en pointe. Cela grâce notamment à l’influence de Steward Brand, le créateur d'un catalogue interactif, qui peut être considéré comme l'ancêtre analogique des groupes de discussions numériques apparus des années plus tard<ref>{{Lien web|auteur=Guillaume de Lamérie|titre=Aux sources de l’utopie numérique, de la contre-culture à la cyberculture|url=https://web.archive.org/web/20211021183032/https://www.afis.org/Aux-sources-de-l-utopie-numerique|site=Association française pour l’Information Scientifique|éditeur=|date=18 septembre 2013|consulté le=}}.</ref>. Comme autre indication, il y a ensuite les propos tenus en 1992, lors d’une plénière de la 24ᵉ réunion du groupe de travail sur l’ingénierie Internet, par [[w:fr : David D. Clark|David D. Clark]], un autre pionnier d’Internet. Durant cette rencontre, ce chef de projet prononça des paroles<ref>{{Article|prénom1=Andrew L.|nom1=Russell|titre='Rough Consensus and Running Code' and the Internet-OSI Standards War|périodique=IEEE Annals Hist. Comput. IEEE Annals of the History of Computing|volume=28|numéro=3|date=2006|issn=1058-6180|pages=48–61}}.</ref> restées dans les annales. « Nous récusons rois, présidents et votes. Nous croyons au consensus et aux programmes qui tournent »<ref>Texte original avant sa traduction par www.deepl.com/translator : « ''We reject: kings, presidents and voting. We believe: in rough consensus and running code »''</ref>. Deux phrases seulement, mais qui, dans le cadre du milieu informatique, permettent de croire que le mépris de la contre-culture envers la technique et la science, s'est transformé en un refus d’autorité et une recherche de consensus. Dans tous les cas, le développement du réseau Internet ne s'est pas fait sans conflits idéologiques importants. On peut d'ailleurs se demander aujourd'hui à quoi ressemblerait Internet s'il n'avait jamais été [[w:en:Commercialization_of_the_Internet|commercialisé]]<ref>{{Ouvrage|langue=en|prénom1=Shane|nom1=Greenstein|titre=How the Internet Became Commercial: Innovation, Privatization, and the Birth of a New Network|passage=79|éditeur=Princeton University Press|date=2017-09-26|isbn=978-0-691-17839-4|consulté le=2025-12-27}}</ref>. Cela s'est passé en novembre 1994, lorsque l’association sans but lucratif ''[[w:en:Advanced_Network_and_Services|Advanced Network and Services,]] chargée de g''érer les accès à Internet, a fait le choix de vendre ses activités. Cette décision faisait suite à un appel à des fonds privés pour financer d'importants changements dans l'infrastructure du réseau. Profitant de l'occasion, la société commerciale ''[[w:AOL|America Online]]'' a ainsi repris à son compte la gestion des connexions à Internet, après avoir effectué un versement de 35 millions de dollars<ref>{{Lien web|titre=ANS sold to America On-line|url=https://web.archive.org/web/20110927083123/http://www.merit.edu/mail.archives/mjts/1994-11/msg00023.html|site=www.merit.edu|auteur=Jeff.Ogden}}</ref>. Trente ans plus tard, Internet est devenu ce réseau que nous expérimentons aujourd'hui, à savoir, un réseau dominé par des sociétés privées les plus riches au monde. Dans ce contexte et parmi les 100 [[w:_Liste_des_sites_web_les_plus_visités|sites web les plus visités au monde]], seul le [[w:Nom_de_domaine|nom de domaine]] Wikipédia appartient à une organisation non lucrative<ref>{{Lien web|titre=Top 100 Most Visited Websites Worldwide (August 2025)|url=https://web.archive.org/web/20250923022332/https://www.similarweb.com/blog/research/market-research/most-visited-websites/|auteur=Similarweb}}</ref>. Cela explique donc pourquoi le mouvement Wikimédia, via son encyclopédie et la fondation qui l'héberge, représente à ce jour, et dans l'espace web, l'expression la plus visible de la philosophie des pionniers d’Internet. Plus qu'un héritage, cette situation peut être vue comme une mission perpétuée au sein d'un espace envahi par une culture marchande et capitaliste. C'est là une information importante qu'il faut retenir au sujet du mouvement Wikimédia. Elle ne clôture pas pour autant tout ce qu'il faut savoir au sujet des évènements qui ont permis la création d’une encyclopédie mondiale, libre et collaborative. En revanche, elle nous invite à découvrir l'histoire du World Wide Web, un espace numérique sans lequel la création de Wikipédia n'aurait jamais été possible.{{AutoCat}} ju5fmyi5xvoc8jw800bj9gkpkbeccax 765187 765186 2026-04-27T07:36:15Z Lionel Scheepmans 20012 765187 wikitext text/x-wiki <noinclude>{{Le mouvement Wikimédia}}</noinclude> L’[[w:fr : Histoire d'Internet|histoire du réseau Internet]] constitue un nouvel épisode captivant de la révolution numérique, sans lequel le mouvement Wikimédia n’aurait jamais pu émerger. D’un point de vue purement technique, ce réseau informatique a été mis au point dans les années 1970, avant l’adoption généralisée du protocole [[w:fr : Suite des protocoles Internet|TCP/IP]], toujours en usage à ce jour. Ce dernier fut inventé par [[w:Vint_Cerf|Vint Cerf]] et [[w:fr : Bob Kahn|Robert Elliot Kahn]], quand ils travaillaient pour la ''[[w:fr : Defense Advanced Research Projects Agency|Defense Advanced Research Projects Agency]]'', rattachée au département de la Défense américaine<ref name="Chemla">{{Ouvrage|langue=|auteur=Laurent Chemla|prénom1=Djilali|nom1=Benamrane|nom2=Biens publics à l'échelle mondiale|nom3=Coopération solidarité développement aux PTT|titre=Les télécommunications, entre bien public et marchandise|passage=73 & 63 (par ordre de citation)|lieu=Une histoire d'Internet|éditeur=ECLM (Charles Leopold Mayer)|date=2005|pages totales=|isbn=978-2-84377-111-8|oclc=833154536|lire en ligne=|consulté le=}}.</ref>. L'une des premières présentations de leur projet fut, entre autres, réalisée lors d’une conférence organisée par l’[[w:fr : International Network Working Group|International Network Working Group]], une instance créée pour assurer la gouvernance mondiale du réseau informatique. Sur base de ces informations, on peut penser qu’Internet a été créé par des militaires. Cependant, ''[[w:Une_contre-histoire_de_l'Internet|Une contre-histoire de l’Internet]]''<ref>{{Ouvrage|éditeur=Premieres Lignes Television|titre=Une contre-histoire de l'Internet|année=2013|auteur=[[w:Sylvain Bergère|]]}}</ref>, nous révèle que les créateurs et les premiers utilisateurs d’[[w:ARPANET|ARPANET]], considéré comme l’ancêtre d’Internet, étaient davantage des étudiants [[w:Hippies|hippies]] et amateurs de [[w:LSD|LSD]], que des militaires bien drillés. D’ailleurs, avant la standardisation du protocole TCP/IP, ARPANET fonctionnait depuis plus d’un an avec un autre protocole de transition intitulé [[w:Network_Control_Program_(Arpanet)|''Network Control Program'']]. Or, celui-ci avait été mis au point, en février 1969, par le [[w:fr : Network Working Group|''Network Working Group'']], un groupe informel d’étudiants rassemblés autour de [[w:Steve_Crocker|Steve Crocker]], lorsqu’il ne détenait encore qu’une simple licence. [[Fichier:Internet_map_1024.jpg|alt=Nuage filandreux de lignes multicolores|vignette|<small>Figure 10. Carte partielle d’Internet, créée sur base des données d’opte.org en date du 15 juin 2005.</small>|gauche|300x300px]] Bien que rarement cité dans l’histoire d’Internet, ce groupe a pourtant mis en place la procédure RFC, pour ''[[w:Request_for_comments|Request For Comments]],'' reconnue comme « l’un des symboles forts de la "culture technique" de l’Internet, marquée par l’égalitarisme, l’autogestion et la recherche collective de l’efficience »<ref name="Serres">{{Article|auteur=|prénom1=Alexandre|nom1=Serres|prénom2=Christian|nom2=Le Moënne|prénom3=Jean-Max|nom3=Noyer|nom4=|titre=Aux sources d'internet : l'émergence d'Arpanet : exploration du processus d'émergence d'une infrastructure informationnelle : description des trajectoires des acteurs et actants, des filières et des réseaux constitutifs de la naissance d'Arpanet : problèmes critiques et épistémologiques posés par l'histoire des innovations|périodique=Thèse de doctorat|éditeur=Université Rennes 2|date=2000|issn=|lire en ligne=https://tel.archives-ouvertes.fr/tel-00312005/document|pages=481 & 488 (par ordre de citation)}}.</ref>. Soit trois principes et une procédure, qui aujourd’hui encore sont appliqués sur le site Méta-Wiki, dans lequel s'organise la gestion communautaire du mouvement Wikimédia. Cela alors qu'au sein des projets pédagogiques, d’autres processus similaires de recherche de consensus ont fait leur apparition. Il faut ensuite savoir que les liens entre ARPANET et l’armée ont disparu avec l’apparition du [[w:en : MILNET|MILNET]], un réseau entièrement dédié aux activités militaires, rebaptisé [[w:en:NIPRNet|NIPRNet]], pour Non-classified Internet Protocol Router Network, en 1990. Après une séparation définitive en 1983, précisément l’année où [[w:Richard_Stallman|Richard Stallman]] postait sa demande d’aide pour le [[w:Projet_GNU|projet GNU]], le réseau ARPANET resta uniquement dédié à la recherche et au développement<ref>{{Ouvrage|langue=|auteur=|prénom1=Stephen|nom1=Denneti|titre=ARPANET Information Brochure|passage=4|lieu=|éditeur=Defense Communications Agency|date=1978|pages totales=46|isbn=|oclc=476024876|lire en ligne=https://web.archive.org/web/20200710174908/https://apps.dtic.mil/dtic/tr/fulltext/u2/a164353.pdf}}.</ref>. À cette époque, le réseau ne comprenait pas plus de 600 machines connectées<ref>{{Ouvrage|langue=|auteur=|prénom1=Solange|nom1=Ghernaouti-Hélie|prénom2=Arnaud|nom2=Dufour|titre=Internet|passage=|lieu=|éditeur=Presses universitaires de France|date=2012|pages totales=|isbn=978-2-13-058548-0|oclc=795497443|lire en ligne=|consulté le=}}.</ref>, ce qui n'a donc rien de comparable avec ce vaste réseau informatique mondial que nous connaissons aujourd’hui, et qui fut fortement développé au cours des années 1990. Pour en assurer l’entretien technique, une [[w:Organisation_non_gouvernementale|organisation non gouvernementale]], a été créée en 1992, sous l'appellation d’''[[w:fr : Internet Society|Internet Society]]''. Celle-ci devait aussi veiller au respect des valeurs fondamentales liées au bon fonctionnement du réseau<ref>{{Lien web|langue=|auteur=Étienne Combier|titre=Les leçons de l’Internet Society pour sauver la Toile|url=https://web.archive.org/web/20201024101959/https://www.lesechos.fr/2017/09/les-lecons-de-linternet-society-pour-sauver-la-toile-182263|site=Les Echos|éditeur=|date=2017-09-19|consulté le=}}.</ref>. Car avant d'atteindre des milliards d’appareils connectés, il a d’abord fallu réglementer les nombreuses [[w:Dorsale Internet|dorsales internet]] intercontinentales, sans lesquelles la transmission du protocole TCP/IP partout dans le monde n'aurait pas été possible. Pour en revenir à l’état d’esprit des créateurs d'Internet, un article intitulé ''Quarante ans après : mais qui donc créa l’internet ?'' apporte un éclairage particulièrement intéressant au sujet des liens que l'on peut établir entre le mouvement Wikimédia et l'[[w:Histoire_d'Internet|histoire d'Internet]]. Dans son témoignage, [[w:fr:Michel Elie|Michel Elie]], cet ingénieur en informatique, membre du ''Network Working Group'' cité précédemment, et responsable de l’[[w:fr : Observatoire des usages de l'Internet|Observatoire des Usages de l’Internet]], nous explique effectivement ceci. <blockquote> Le succès de l’internet, nous le devons aux bons choix initiaux et à la dynamique qui en est résultée : la collaboration de dizaines de milliers d’étudiants, ou de bénévoles apportant leur expertise, tels par exemple ces centaines de personnes qui enrichissent continuellement des encyclopédies en ligne telles que Wikipédia.''<ref>{{Lien web|langue=|auteur=Michel Elie|titre=Quarante ans après : mais qui donc créa l'internet ?|url=https://web.archive.org/web/20200131180536/https://vecam.org/archives/article1123.html|site=Vecam|lieu=|date=2009|consulté le=}}.</ref>'' </blockquote> Au courant des années 1990, le milieu informatique universitaire semblait donc toujours fortement imprégné des idéaux de la [[w:Contre-culture des années 1960|contre-culture des années 1960]], produit par les [[w:Baby_boomer|''baby boomers'']] dans le contexte de la [[w:Guerre_du_Vietnam|guerre du Vietnam]]. Afin d'illustrer les idées véhiculées à cette époque, voici un paragraphe extrait d'un ouvrage publié en 1970 et intitulé ''Vers une contre-culture : Réflexions sur la société technocratique et l’opposition de la jeunesse''<ref>{{Ouvrage|langue=|prénom1=Theodore|nom1=Roszak|prénom2=Claude|nom2=Elsen|titre=Vers une contre-culture. Réflexions sur la société technocratique et l'opposition de la jeunesse|passage=266-267|lieu=Paris|éditeur=Stock|date=1970|pages totales=318|isbn=978-2-234-01282-0|oclc=36236326}}.</ref>. Dans celui-ci, [[w:fr : Theodore Roszak|Théodore Roszak]] explique que : <blockquote> Le projet essentiel de notre contre-culture : proclamer un nouveau ciel et une nouvelle terre, si vastes, si merveilleux que les prétentions démesurées de la technique soient réduites à n’occuper dans la vie humaine qu’une place inférieure et marginale. Créer et répandre une telle conception de la vie n’implique rien de moins que l’acceptation de nous ouvrir à l’imagination visionnaire. Nous devons être prêts à soutenir ce qu’affirment des personnes telles que [[w:fr : William Blake|Blake]], à savoir que certains yeux ne voient pas le monde comme le voient le regard banal ou l’œil scientifique, mais le voient transformé, dans une lumière éclatante et, ce faisant, le voient tel qu’il est vraiment. </blockquote> À la suite de cette lecture, il peut sembler paradoxal de penser qu’une contre-culture, voyant la technique comme « inférieure » et assimilant la science au « banal », puisse avoir un lien avec le milieu scientifique universitaire qui fut à l'origine d'Internet. Cependant, une réponse à cette énigme fut apportée par [[w:Fred_Turner_(professeur)|Fred Turner]], par la publication de son livre intitulé : « ''Aux sources de l’utopie numérique : De la contre-culture à la cyberculture, [[w:Stewart_Brand|Stewart Brand]], un homme d’influence »''<ref>{{Ouvrage|langue=fr|prénom1=Fred|nom1=Turner|titre=Aux sources de l'utopie numérique: De la contre-culture à la cyberculture, Stewart Brand, un homme d'influence|éditeur=C & F Éditions|date=2021-07-07|isbn=978-2-37662-032-7}}.</ref>. Grâce à cet ouvrage, on découvre en effet que le mouvement Hippie utilisera tout ce qui était à sa disposition à l’époque pour parvenir à ses fins : LSD, spiritualités alternatives, mais également, objets technologiques les plus en pointe. Cela grâce notamment à l’influence de Steward Brand, le créateur d'un catalogue interactif, qui peut être considéré comme l'ancêtre analogique des groupes de discussions numériques apparus des années plus tard<ref>{{Lien web|auteur=Guillaume de Lamérie|titre=Aux sources de l’utopie numérique, de la contre-culture à la cyberculture|url=https://web.archive.org/web/20211021183032/https://www.afis.org/Aux-sources-de-l-utopie-numerique|site=Association française pour l’Information Scientifique|éditeur=|date=18 septembre 2013|consulté le=}}.</ref>. Comme autre indication, il y a ensuite les propos tenus en 1992, lors d’une plénière de la 24ᵉ réunion du groupe de travail sur l’ingénierie Internet, par [[w:fr : David D. Clark|David D. Clark]], un autre pionnier d’Internet. Durant cette rencontre, ce chef de projet prononça des paroles<ref>{{Article|prénom1=Andrew L.|nom1=Russell|titre='Rough Consensus and Running Code' and the Internet-OSI Standards War|périodique=IEEE Annals Hist. Comput. IEEE Annals of the History of Computing|volume=28|numéro=3|date=2006|issn=1058-6180|pages=48–61}}.</ref> restées dans les annales. « Nous récusons rois, présidents et votes. Nous croyons au consensus et aux programmes qui tournent »<ref>Texte original avant sa traduction par www.deepl.com/translator : « ''We reject: kings, presidents and voting. We believe: in rough consensus and running code »''</ref>. Deux phrases seulement, mais qui, dans le cadre du milieu informatique, permettent de croire que le mépris de la contre-culture envers la technique et la science, s'est transformé en un refus d’autorité et une recherche de consensus. Dans tous les cas, le développement du réseau Internet ne s'est pas fait sans conflits idéologiques importants. On peut d'ailleurs se demander aujourd'hui à quoi ressemblerait Internet s'il n'avait jamais été [[w:en:Commercialization_of_the_Internet|commercialisé]]<ref>{{Ouvrage|langue=en|prénom1=Shane|nom1=Greenstein|titre=How the Internet Became Commercial: Innovation, Privatization, and the Birth of a New Network|passage=79|éditeur=Princeton University Press|date=2017-09-26|isbn=978-0-691-17839-4|consulté le=2025-12-27}}</ref>. Cela s'est passé en novembre 1994, lorsque l’association sans but lucratif ''[[w:en:Advanced_Network_and_Services|Advanced Network and Services,]] chargée de g''érer les accès à Internet, a fait le choix de vendre ses activités. Cette décision faisait suite à un appel à des fonds privés pour financer d'importants changements dans l'infrastructure du réseau. Profitant de l'occasion, la société commerciale ''[[w:AOL|America Online]]'' a ainsi repris à son compte la gestion des connexions à Internet, après avoir effectué un versement de 35 millions de dollars<ref>{{Lien web|titre=ANS sold to America On-line|url=https://web.archive.org/web/20110927083123/http://www.merit.edu/mail.archives/mjts/1994-11/msg00023.html|site=www.merit.edu|auteur=Jeff.Ogden}}</ref>. Trente ans plus tard, Internet est devenu ce réseau que nous expérimentons aujourd'hui, à savoir, un réseau dominé par des sociétés privées les plus riches au monde. Dans ce contexte et parmi les 100 [[w:_Liste_des_sites_web_les_plus_visités|sites web les plus visités au monde]], seul le [[w:Nom_de_domaine|nom de domaine]] Wikipédia appartient à une organisation non lucrative<ref>{{Lien web|titre=Top 100 Most Visited Websites Worldwide (August 2025)|url=https://web.archive.org/web/20250923022332/https://www.similarweb.com/blog/research/market-research/most-visited-websites/|auteur=Similarweb}}</ref>. Cela explique donc pourquoi le mouvement Wikimédia, via son encyclopédie et la fondation qui l'héberge, représente à ce jour, et dans l'espace web, l'expression la plus visible de la philosophie des pionniers d’Internet. Plus qu'un héritage, cette situation peut être vue comme une mission perpétuée au sein d'un espace envahi par une culture marchande et capitaliste. C'est là une information importante qu'il faut retenir au sujet du mouvement Wikimédia. Elle ne clôture pas pour autant tout ce qu'il faut savoir au sujet des évènements qui ont permis la création d’une encyclopédie mondiale, libre et collaborative. En revanche, elle nous invite à découvrir l'histoire du World Wide Web, un espace numérique sans lequel la création de Wikipédia n'aurait jamais été possible.{{AutoCat}} sgtj7mb2e2h5wkdeablta7wph4nvg6h 765188 765187 2026-04-27T07:43:04Z Lionel Scheepmans 20012 765188 wikitext text/x-wiki <noinclude>{{Le mouvement Wikimédia}}</noinclude> L’[[w:fr : Histoire d'Internet|histoire du réseau Internet]] constitue un nouvel épisode captivant de la révolution numérique, sans lequel le mouvement Wikimédia n’aurait jamais pu émerger. D’un point de vue purement technique, ce réseau informatique a été mis au point dans les années 1970, avant l’adoption généralisée du protocole [[w:fr : Suite des protocoles Internet|TCP/IP]], toujours en usage à ce jour. Ce dernier fut inventé par [[w:Vint_Cerf|Vint Cerf]] et [[w:fr : Bob Kahn|Robert Elliot Kahn]], quand ils travaillaient pour la ''[[w:fr : Defense Advanced Research Projects Agency|Defense Advanced Research Projects Agency]]'', rattachée au département de la Défense américaine<ref name="Chemla">{{Ouvrage|langue=|auteur=Laurent Chemla|prénom1=Djilali|nom1=Benamrane|nom2=Biens publics à l'échelle mondiale|nom3=Coopération solidarité développement aux PTT|titre=Les télécommunications, entre bien public et marchandise|passage=73 & 63 (par ordre de citation)|lieu=Une histoire d'Internet|éditeur=ECLM (Charles Leopold Mayer)|date=2005|pages totales=|isbn=978-2-84377-111-8|oclc=833154536|lire en ligne=|consulté le=}}.</ref>. L'une des premières présentations de leur projet fut, entre autres, réalisée lors d’une conférence organisée par l’[[w:fr : International Network Working Group|International Network Working Group]], une instance créée pour assurer la gouvernance mondiale du réseau informatique. Sur base de ces informations, on peut penser qu’Internet a été créé par des militaires. Cependant, ''[[w:Une_contre-histoire_de_l'Internet|Une contre-histoire de l’Internet]]''<ref>{{Ouvrage|éditeur=Premieres Lignes Television|titre=Une contre-histoire de l'Internet|année=2013|auteur=[[w:Sylvain Bergère|]]}}</ref>, nous révèle que les créateurs et les premiers utilisateurs d’[[w:ARPANET|ARPANET]], considéré comme l’ancêtre d’Internet, étaient davantage des étudiants [[w:Hippies|hippies]] et amateurs de [[w:LSD|LSD]], que des militaires bien drillés. D’ailleurs, avant la standardisation du protocole TCP/IP, ARPANET fonctionnait depuis plus d’un an avec un autre protocole de transition intitulé [[w:Network_Control_Program_(Arpanet)|''Network Control Program'']]. Or, celui-ci avait été mis au point, en février 1969, par le [[w:fr : Network Working Group|''Network Working Group'']], un groupe informel d’étudiants rassemblés autour de [[w:Steve_Crocker|Steve Crocker]], lorsqu’il ne détenait encore qu’une simple licence. [[Fichier:Internet_map_1024.jpg|alt=Nuage filandreux de lignes multicolores|vignette|<small>Figure 10. Carte partielle d’Internet, créée sur base des données d’opte.org en date du 15 juin 2005.</small>|gauche|300x300px]] Bien que rarement cité dans l’histoire d’Internet, ce groupe a pourtant mis en place la procédure RFC, pour ''[[w:Request_for_comments|Request For Comments]],'' reconnue comme « l’un des symboles forts de la "culture technique" de l’Internet, marquée par l’égalitarisme, l’autogestion et la recherche collective de l’efficience »<ref name="Serres">{{Article|auteur=|prénom1=Alexandre|nom1=Serres|prénom2=Christian|nom2=Le Moënne|prénom3=Jean-Max|nom3=Noyer|nom4=|titre=Aux sources d'internet : l'émergence d'Arpanet : exploration du processus d'émergence d'une infrastructure informationnelle : description des trajectoires des acteurs et actants, des filières et des réseaux constitutifs de la naissance d'Arpanet : problèmes critiques et épistémologiques posés par l'histoire des innovations|périodique=Thèse de doctorat|éditeur=Université Rennes 2|date=2000|issn=|lire en ligne=https://tel.archives-ouvertes.fr/tel-00312005/document|pages=481 & 488 (par ordre de citation)}}.</ref>. Soit trois principes et une procédure, qui aujourd’hui encore sont appliqués sur le site Méta-Wiki, dans lequel s'organise la gestion communautaire du mouvement Wikimédia. Cela alors qu'au sein des projets pédagogiques, d’autres processus similaires de recherche de consensus ont fait leur apparition. Il faut ensuite savoir que les liens entre ARPANET et l’armée ont disparu avec l’apparition du [[w:en : MILNET|MILNET]], un réseau entièrement dédié aux activités militaires, rebaptisé [[w:en:NIPRNet|NIPRNet]], pour Non-classified Internet Protocol Router Network, en 1990. Après une séparation définitive en 1983, précisément l’année où [[w:Richard_Stallman|Richard Stallman]] postait sa demande d’aide pour le [[w:Projet_GNU|projet GNU]], le réseau ARPANET resta uniquement dédié à la recherche et au développement<ref>{{Ouvrage|langue=|auteur=|prénom1=Stephen|nom1=Denneti|titre=ARPANET Information Brochure|passage=4|lieu=|éditeur=Defense Communications Agency|date=1978|pages totales=46|isbn=|oclc=476024876|lire en ligne=https://web.archive.org/web/20200710174908/https://apps.dtic.mil/dtic/tr/fulltext/u2/a164353.pdf}}.</ref>. À cette époque, le réseau ne comprenait pas plus de 600 machines connectées<ref>{{Ouvrage|langue=|auteur=|prénom1=Solange|nom1=Ghernaouti-Hélie|prénom2=Arnaud|nom2=Dufour|titre=Internet|passage=|lieu=|éditeur=Presses universitaires de France|date=2012|pages totales=|isbn=978-2-13-058548-0|oclc=795497443|lire en ligne=|consulté le=}}.</ref>, ce qui n'a donc rien de comparable avec ce vaste réseau informatique mondial que nous connaissons aujourd’hui, et qui fut fortement développé au cours des années 1990. Pour en assurer l’entretien technique, une [[w:Organisation_non_gouvernementale|organisation non gouvernementale]], a été créée en 1992, sous l'appellation d’''[[w:fr : Internet Society|Internet Society]]''. Celle-ci devait aussi veiller au respect des valeurs fondamentales liées au bon fonctionnement du réseau<ref>{{Lien web|langue=|auteur=Étienne Combier|titre=Les leçons de l’Internet Society pour sauver la Toile|url=https://web.archive.org/web/20201024101959/https://www.lesechos.fr/2017/09/les-lecons-de-linternet-society-pour-sauver-la-toile-182263|site=Les Echos|éditeur=|date=2017-09-19|consulté le=}}.</ref>. Car avant d'atteindre des milliards d’appareils connectés, il a d’abord fallu réglementer les nombreuses [[w:Dorsale Internet|dorsales internet]] intercontinentales, sans lesquelles la transmission du protocole TCP/IP partout dans le monde n'aurait pas été possible. Pour en revenir à l’état d’esprit des créateurs d'Internet, un article intitulé ''Quarante ans après : mais qui donc créa l’internet ?'' apporte un éclairage particulièrement intéressant au sujet des liens que l'on peut établir entre le mouvement Wikimédia et l'[[w:Histoire_d'Internet|histoire d'Internet]]. Dans son témoignage, [[w:fr:Michel Elie|Michel Elie]], cet ingénieur en informatique, membre du ''Network Working Group'' cité précédemment, et responsable de l’[[w:fr : Observatoire des usages de l'Internet|Observatoire des Usages de l’Internet]], nous explique effectivement ceci. <blockquote> Le succès de l’internet, nous le devons aux bons choix initiaux et à la dynamique qui en est résultée : la collaboration de dizaines de milliers d’étudiants, ou de bénévoles apportant leur expertise, tels par exemple ces centaines de personnes qui enrichissent continuellement des encyclopédies en ligne telles que Wikipédia.''<ref>{{Lien web|langue=|auteur=Michel Elie|titre=Quarante ans après : mais qui donc créa l'internet ?|url=https://web.archive.org/web/20200131180536/https://vecam.org/archives/article1123.html|site=Vecam|lieu=|date=2009|consulté le=}}.</ref>'' </blockquote> Au courant des années 1990, le milieu informatique universitaire semblait donc toujours fortement imprégné des idéaux de la [[w:Contre-culture des années 1960|contre-culture des années 1960]], produit par les [[w:Baby_boomer|''baby boomers'']] dans le contexte de la [[w:Guerre_du_Vietnam|guerre du Vietnam]]. Afin d'illustrer les idées véhiculées à cette époque, voici un paragraphe extrait d'un ouvrage publié en 1970 et intitulé ''Vers une contre-culture : Réflexions sur la société technocratique et l’opposition de la jeunesse''<ref>{{Ouvrage|langue=|prénom1=Theodore|nom1=Roszak|prénom2=Claude|nom2=Elsen|titre=Vers une contre-culture. Réflexions sur la société technocratique et l'opposition de la jeunesse|passage=266-267|lieu=Paris|éditeur=Stock|date=1970|pages totales=318|isbn=978-2-234-01282-0|oclc=36236326}}.</ref>. Dans celui-ci, [[w:fr : Theodore Roszak|Théodore Roszak]] explique que : <blockquote> Le projet essentiel de notre contre-culture : proclamer un nouveau ciel et une nouvelle terre, si vastes, si merveilleux que les prétentions démesurées de la technique soient réduites à n’occuper dans la vie humaine qu’une place inférieure et marginale. Créer et répandre une telle conception de la vie n’implique rien de moins que l’acceptation de nous ouvrir à l’imagination visionnaire. Nous devons être prêts à soutenir ce qu’affirment des personnes telles que [[w:fr : William Blake|Blake]], à savoir que certains yeux ne voient pas le monde comme le voient le regard banal ou l’œil scientifique, mais le voient transformé, dans une lumière éclatante et, ce faisant, le voient tel qu’il est vraiment. </blockquote> À la suite de cette lecture, il peut sembler paradoxal de penser qu’une contre-culture, voyant la technique comme « inférieure » et assimilant la science au « banal », puisse avoir un lien avec le milieu scientifique universitaire qui fut à l'origine d'Internet. Cependant, une réponse à cette énigme fut apportée par [[w:Fred_Turner_(professeur)|Fred Turner]], par la publication de son livre intitulé : « ''Aux sources de l’utopie numérique : De la contre-culture à la cyberculture, [[w:Stewart_Brand|Stewart Brand]], un homme d’influence »''<ref>{{Ouvrage|langue=fr|prénom1=Fred|nom1=Turner|titre=Aux sources de l'utopie numérique: De la contre-culture à la cyberculture, Stewart Brand, un homme d'influence|éditeur=C & F Éditions|date=2021-07-07|isbn=978-2-37662-032-7}}.</ref>. Grâce à cet ouvrage, on découvre en effet que le mouvement Hippie utilisera tout ce qui était à sa disposition à l’époque pour parvenir à ses fins : LSD, spiritualités alternatives, mais également, objets technologiques les plus en pointe. Cela grâce notamment à l’influence de Steward Brand, le créateur d'un catalogue interactif, qui peut être considéré comme l'ancêtre analogique des groupes de discussions numériques apparus des années plus tard<ref>{{Lien web|auteur=Guillaume de Lamérie|titre=Aux sources de l’utopie numérique, de la contre-culture à la cyberculture|url=https://web.archive.org/web/20211021183032/https://www.afis.org/Aux-sources-de-l-utopie-numerique|site=Association française pour l’Information Scientifique|éditeur=|date=18 septembre 2013|consulté le=}}.</ref>. Comme autre indication, il y a ensuite les propos tenus en 1992, lors d’une plénière de la 24ᵉ réunion du groupe de travail sur l’ingénierie Internet, par [[w:fr : David D. Clark|David D. Clark]], un autre pionnier d’Internet. Durant cette rencontre, ce chef de projet prononça des paroles<ref>{{Article|prénom1=Andrew L.|nom1=Russell|titre='Rough Consensus and Running Code' and the Internet-OSI Standards War|périodique=IEEE Annals Hist. Comput. IEEE Annals of the History of Computing|volume=28|numéro=3|date=2006|issn=1058-6180|pages=48–61}}.</ref> restées dans les annales. « Nous récusons rois, présidents et votes. Nous croyons au consensus et aux programmes qui tournent »<ref>Texte original avant sa traduction par www.deepl.com/translator : « ''We reject: kings, presidents and voting. We believe: in rough consensus and running code »''</ref>. Deux phrases seulement, mais qui, dans le cadre du milieu informatique, permettent de croire que le mépris de la contre-culture envers la technique et la science, s'est transformé en un refus d’autorité et une recherche de consensus. Dans tous les cas, le développement du réseau Internet ne s'est pas fait sans conflits idéologiques importants. On peut d'ailleurs se demander aujourd'hui à quoi ressemblerait Internet s'il n'avait jamais été [[w:en:Commercialization_of_the_Internet|commercialisé]]<ref>{{Ouvrage|langue=en|prénom1=Shane|nom1=Greenstein|titre=How the Internet Became Commercial: Innovation, Privatization, and the Birth of a New Network|passage=79|éditeur=Princeton University Press|date=2017-09-26|isbn=978-0-691-17839-4|consulté le=2025-12-27}}</ref>. Cela s'est passé en novembre 1994, lorsque l’association sans but lucratif ''[[w:en:Advanced_Network_and_Services|Advanced Network and Services,]] chargée de g''érer les accès à Internet, a fait le choix de vendre ses activités. Cette décision faisait suite à un appel à des fonds privés pour financer d'importants changements dans l'infrastructure du réseau. Profitant de l'occasion, la société commerciale ''[[w:AOL|America Online]]'' a ainsi repris à son compte la gestion des connexions à Internet, après avoir effectué un versement de 35 millions de dollars<ref>{{Lien web|titre=ANS sold to America On-line|url=https://web.archive.org/web/20110927083123/http://www.merit.edu/mail.archives/mjts/1994-11/msg00023.html|site=www.merit.edu|auteur=Jeff.Ogden}}</ref>. Trente ans plus tard, Internet est devenu ce réseau que nous expérimentons aujourd'hui, à savoir, un réseau dominé par des sociétés privées les plus riches au monde. Dans ce contexte et parmi les 100 [[w:_Liste_des_sites_web_les_plus_visités|sites web les plus visités au monde]], seul le [[w:Nom_de_domaine|nom de domaine]] Wikipédia appartient à une organisation non lucrative<ref>{{Lien web|titre=Top 100 Most Visited Websites Worldwide (August 2025)|url=https://web.archive.org/web/20250923022332/https://www.similarweb.com/blog/research/market-research/most-visited-websites/|auteur=Similarweb}}</ref>. Cela explique donc pourquoi le mouvement Wikimédia, via son encyclopédie et la fondation qui l'héberge, représente à ce jour, et dans l'espace web, l'expression la plus visible de la philosophie des pionniers d’Internet. Plus qu'un héritage, cette situation peut être vue comme une mission perpétuée au sein d'un espace envahi par une culture marchande et capitaliste. C'est là une information importante qu'il faut retenir au sujet du mouvement Wikimédia. Elle ne clôture pas pour autant tout ce qu'il faut savoir au sujet des évènements qui ont permis la création d’une encyclopédie mondiale, libre et collaborative. Mais en revanche, elle nous invite à découvrir l'histoire du World Wide Web, un espace numérique sans lequel la création de Wikipédia n'aurait jamais été possible.{{AutoCat}} se6m8zu70i1mdv1eav0r6fpf5y6v9ec Les cartes graphiques/Le support matériel du lancer de rayons 0 80578 765160 764893 2026-04-26T20:22:19Z Mewtow 31375 /* Les avantages et désavantages comparé à la rastérisation */ 765160 wikitext text/x-wiki Les cartes graphiques actuelles utilisent la technique de la rastérisation, qui a été décrite en détail dans le chapitre sur les cartes accélératrices 3D. Mais nous avions dit qu'il existe une seconde technique générale pour le rendu 3D, totalement opposée à la rastérisation, appelée le '''lancer de rayons'''. Cette technique a cependant été peu utilisée dans les jeux vidéo, jusqu'à récemment. La raison est que le lancer de rayons demande beaucoup de puissance de calcul, sans compter que créer des cartes accélératrices pour le lancer de rayons n'est pas simple. Mais les choses commencent à changer. Quelques jeux vidéos récents intègrent des techniques de lancer rayons, pour compléter un rendu effectué principalement en rastérisation. De plus, les cartes graphiques modernes incorporent quelques circuits pour accélérer le lancer de rayons, même s'ils restent marginaux des compléments au rendu par rastérisation. S'il a existé des cartes accélératrices totalement dédiées au rendu en lancer de rayons, elles sont restées confidentielles. Aussi, nous allons nous concentrer sur les cartes graphiques récentes, et allons peu parler des cartes accélératrices dédiées au lancer de rayons. ==Le lancer de rayons== Le lancer de rayons et la rastérisation. commencent par générer la géométrie de la scène 3D, en plaçant les objets dans la scène 3D, et en effectuant l'étape de transformation des modèles 3D, et les étapes de transformation suivante. Mais les ressemblances s'arrêtent là. Le lancer de rayons effectue l'étape d'éclairage différemment, sans compter qu'il n'a pas besoin de rastérisation. ===Le ''ray-casting'' : des rayons tirés depuis la caméra=== La forme la plus simple de lancer de rayon s'appelle le '''''ray-casting'''''. Elle émet des lignes droites, des '''rayons''' qui partent de la caméra et qui passent chacun par un pixel de l'écran. Les rayons font alors intersecter les différents objets présents dans la scène 3D, en un point d'intersection. Le moteur du jeu détermine alors quel est le point d'intersection le plus proche, ou plus précisément, le sommet le plus proche de ce point d'intersection. Ce sommet est associé à une coordonnée de textures, ce qui permet d'associer directement un texel au pixel associé au rayon. [[File:Raytrace trace diagram.png|centre|vignette|upright=2|Raycasting, rayon simple.]] En somme, l'étape de lancer de rayon et le calcul des intersections remplacent l'étape de rasterisation, mais les étapes de traitement de la géométrie et des textures existent encore. Après tout, il faut bien placer les objets dans la scène 3D/2D, faire diverses transformations, les éclairer. On peut gérer la transparence des textures assez simplement, si on connait la transparence sur chaque point d'intersection d'un rayon, information présente dans les textures. [[File:Anarch short gameplay.gif|vignette|Exemple de rendu en ray-casting 2D dans un jeu vidéo.]] En soi, cet algorithme est simple, mais il a déjà été utilisé dans pas mal de jeux vidéos. Mais sous une forme simple, en deux dimensions ! Les premiers jeux IdSoftware, dont Wolfenstein 3D et Catacomb, utilisaient cette méthode de rendu, mais dans un univers en deux dimensions. Cet article explique bien cela : [[Les moteurs de rendu des FPS en 2.5 D\Le moteur de Wolfenstein 3D|Le moteur de rendu de Wolfenstein 3D]]. Au passage, si vous faites des recherches sur le ''raycasting'', vous verrez que le terme est souvent utilisé pour désigner la méthode de rendu de ces vieux FPS, alors que ce n'en est qu'un cas particulier. [[File:Simple raycasting with fisheye correction.gif|centre|vignette|upright=2|Simple raycasting with fisheye correction]] ===Le ''raytracing'' proprement dit=== Le lancer de rayon proprement dit est une forme améliorée de ''raycasting'' dans la gestion de l'éclairage et des ombres est modifiée. Le lancer de rayon calcule les ombres assez simplement, sans recourir à des algorithmes compliqués. L'idée est qu'un point d'intersection est dans l'ombre si un objet se trouve entre lui et une source de lumière. Pour déterminer cela, il suffit de tirer un trait entre les deux et de vérifier s'il y a un obstacle/objet sur le trajet. Si c'est le cas, le point d'intersection n'est pas éclairé par la source de lumière et est donc dans l'ombre. Si ce n'est pas le cas, il est éclairé avec un algorithme d'éclairage. Le trait tiré entre la source de lumière et le point d'intersection est en soi facile : c'est rayon, identique aux rayons envoyés depuis la caméra. La différence est que ces rayons servent à calculer les ombres, ils sont utilisés pour une raison différente. Il faut donc faire la différence entre les '''rayons primaires''' qui partent de la caméra et passent par un pixel de l'écran, et les '''rayon d'ombrage''' qui servent pour le calcul des ombres. [[File:Ray trace diagram.svg|centre|vignette|upright=2|Principe du lancer de rayons.]] Les calculs d'éclairage utilisés pour éclairer/ombrer les points d'intersection vous sont déjà connus : la luminosité est calculée à partir de l'algorithme d'éclairage de Phong, vu dans le chapitre "L'éclairage d'une scène 3D : shaders et T&L". Pour cela, il faut juste récupérer la normale du sommet associé au point d'intersection et l'intensité de la source de lumière, et de calculer les informations manquantes (l'angle normale-rayons de lumière, autres). Il détermine alors la couleur de chaque point d'intersection à partir de tout un tas d'informations. ===Le ''raytracing'' récursif=== <noinclude>[[File:Glasses 800 edit.png|vignette|Image rendue avec le lancer de rayons récursif.]]</noinclude> La technique de lancer de rayons précédente ne gère pas les réflexions, les reflets, des miroirs, les effets de spécularité, et quelques autres effets graphiques de ce style. Pourtant, ils peuvent être implémentés facilement en modifiant le ''raycasting'' d'une manière très simple. Il suffit de relancer des rayons à partir du point d'intersection. La direction de ces '''rayons secondaires''' est calculée en utilisant les lois de la réfraction/réflexion vues en physique. De plus, les rayons secondaires peuvent eux-aussi créer des rayons secondaires quand ils sont reflétés/réfractés, etc. La technique est alors appelée du ''lancer de rayons récursif'', qui est souvent simplement appelée "lancer de rayons". [[File:Recursive raytracing.svg|centre|vignette|upright=2|Lancer de rayon récursif.]] ==Les optimisations du lancer de rayons== Les calculs d'intersections sont très gourmands en puissance de calcul. Sans optimisation, on doit tester l'intersection de chaque rayon avec chaque triangle. Mais diverses optimisations permettent d'économiser des calculs. Elles consistent à regrouper plusieurs triangles ensemble pour rejeter des paquets de triangles en une fois. Pour cela, la carte graphique utilise des '''structures d'accélération''', qui mémorisent les regroupements de triangles, et parfois les triangles eux-mêmes. ===Les volumes englobants=== [[File:BoundingBox.jpg|vignette|Objet englobant : la statue est englobée dans un pavé.]] L'idée est d'englober chaque objet par un pavé appelé un ''volume englobant''. Le tout est illustré ci-contre, avec une statue représentée en 3D. La statue est un objet très complexe, contenant plusieurs centaines ou milliers de triangles, ce qui fait que tester l'intersection d'un rayon avec chaque triangle serait très long. Par contre, on peut tester si le rayon intersecte le volume englobant facilement : il suffit de tester les 6 faces du pavé, soit 12 triangles, pas plus. S'il n'y a pas d'intersection, alors on économise plusieurs centaines ou milliers de tests d'intersection. Par contre, s'il y a intersection, on doit vérifier chaque triangle. Vu que les rayons intersectent souvent peu d'objets, le gain est énorme ! L'usage seul de volumes englobant est une optimisation très performante. Au lieu de tester l'intersection avec chaque triangle, on teste l'intersection avec chaque objet, puis l'intersection avec chaque triangle quand on intersecte chaque volume englobant. On divise le nombre de tests par un facteur quasi-constant, mais de très grande valeur. Il faut noter que les calculs d'intersection sont légèrement différents entre un triangle et un volume englobant. Il faut dire que les volumes englobant sont généralement des pavées, ils utilisent des rectangles, etc. Les différences sont cependant minimales. Mais on peut faire encore mieux. L'idée est de regrouper plusieurs volumes englobants en un seul. Si une dizaine d'objets sont proches, leurs volumes englobants seront proches. Il est alors utile d'englober leurs volumes englobants dans un super-volume englobant. L'idée est que l'on teste d'abord le super-volume englobant, au lieu de tester la dizaine de volumes englobants de base. S'il n'y a pas d'intersection, alors on a économisé une dizaine de tests. Mais si intersection, il y a, alors on doit vérifier chaque sous-volume englobant de base, jusqu'à tomber sur une intersection. Vu que les intersections sont rares, on y gagne plus qu'on y perd. Et on peut faire la même chose avec les super-volumes englobants, en les englobant dans des volumes englobants encore plus grands, et ainsi de suite, récursivement. On obtient alors une '''hiérarchie de volumes englobants''', qui part d'un volume englobant qui contient toute la géométrie, hors skybox, qui lui-même regroupe plusieurs volumes englobants, qui eux-mêmes... [[File:Example of bounding volume hierarchy.svg|centre|vignette|upright=2|Hiérarchie de volumes englobants.]] Le nombre de tests d'intersection est alors grandement réduit. On passe d'un nombre de tests proportionnel aux nombres d'objets à un nombre proportionnel à son logarithme. Plus la scène contient d'objets, plus l'économie est importante. La seule difficulté est de générer la hiérarchie de volumes englobants à partir d'une scène 3D. Divers algorithmes assez rapides existent pour cela, ils créent des volumes englobants différents. Les volumes englobants les plus utilisés dans les cartes 3D sont les '''''axis-aligned bounding boxes (AABB)'''''. La hiérarchie est mémorisée en mémoire RAM, dans une structure de données que les programmeurs connaissent sous le nom d'arbre, et précisément un arbre binaire ou du moins d'un arbre similaire (''k-tree''). Traverser cet arbre pour passer d'un objet englobant à un autre plus petit est très simple, mais a un défaut : on saute d'on objet à un autre en mémoire, les deux sont souvent éloignés en mémoire. La traversée de la mémoire est erratique, sautant d'un point à un autre. On n'est donc dans un cas où les caches fonctionnent mal, ou les techniques de préchargement échouent, où la mémoire devient un point bloquant car trop lente. ===La cohérence des rayons=== Les rayons primaires, émis depuis la caméra, sont proches les uns des autres, et vont tous dans le même sens. Mais ce n'est pas le cas pour des rayons secondaires. Les objets d'une scène 3D ont rarement des surfaces planes, mais sont composés d'un tas de triangles qui font un angle entre eux. La conséquence est que deux rayons secondaires émis depuis deux triangles voisins peuvent aller dans des directions très différentes. Ils vont donc intersecter des objets très différents, atterrir sur des sources de lumières différentes, etc. De tels rayons sont dits '''incohérents''', en opposition aux rayons cohérents qui sont des rayons proches qui vont dans la même direction. Les rayons incohérents sont peu fréquents avec le ''rayctracing'' récursif basique, mais deviennent plus courants avec des techniques avancées comme le ''path tracing'' ou les techniques d'illumination globale. En soi, la présende de rayons incohérents n'est pas un problème et est parfaitement normale. Le problème est que le traitement des rayons incohérent est plus lent que pour les rayons cohérents. Le traitement de deux rayons secondaires voisins demande d'accéder à des données différentes, ce qui donne des accès mémoire très différents et très éloignés. On ne peut pas profiter des mémoires caches ou des optimisations de la hiérarchie mémoire, si on les traite consécutivement. Un autre défaut est qu'une même instance de ''pixels shaders'' va traiter plusieurs rayons en même temps, grâce au SIMD. Mais les branchements n'auront pas les mêmes résultats d'un rayon à l'autre, et cette divergence entrainera des opérations de masquage et de branchement très couteuses. Pour éviter cela, les GPU modernes permettent de trier les rayons en fonction de leur direction et de leur origine. Ils traitent les rayons non dans l'ordre usuel, mais essayent de regrouper des rayons proches qui vont dans la même direction, et de les traiter ensemble, en parallèle, ou l'un après l'autre. Cela ne change rien au rendu final, qui traite les rayons en parallèle. Ainsi, les données chargées dans le cache par le premier rayon seront celles utilisées pour les rayons suivants, ce qui donne un gain de performance appréciable. De plus, cela évite d'utiliser des branchements dans les ''pixels shaders''. Les méthodes de '''tri de rayons''' sont nombreuses, aussi en faire une liste exhaustive serait assez long, sans compter qu'on ne sait pas quelles sont celles implémentées en hardware, ni commetn elles le sont. ===Les avantages et désavantages comparé à la rastérisation=== L'avantage principal du lancer de rayons est la détermination des surfaces visibles. La rastérisation a tendance à calculer inutilement des portions non-rendues de la scène 3D, malgré l'usage de techniques de ''culling'' ou de ''clipping'' aussi puissantes qu'imparfaites. De nombreux fragments/pixels sont éliminés à la toute fin du pipeline, grâce au z-buffer, après avoir été calculés et texturés. Le lancer de rayons ne calcule pas les portions invisibles de l'image par construction : pas besoin de ''culling'', de ''clipping'', ni même de z-buffer. D'ailleurs, l'absence de z-buffer réduit grandement le nombre d'accès mémoire. Les réflexions et la réfraction sont gérées naturellement par le lancer de rayon récursif, de même que l'éclairage et les ombres, alors qu'elles demandent des ruses de sioux pour obtenir un résultat correct avec la rastérisation. Avec la rastérisation, cela demande d'utiliser des techniques de rendu au-dessus de la rastérisation de base, comme des ombres volumétriques, des ''lightmaps'', des ''shadowmaps'' ou autres. L'absence de ''shadowmaps'', qui demande de faire du ''render-to-texture'', élimine certaines écritures mémoires et permet aux caches de texture d'être en lecture seule (leurs circuits sont donc assez simples et performants, les problèmes de cohérence des caches disparaissent). Mais tous ces avantages sont compensés par le fait que le lancer de rayons est plus lent sur un paquet d'autres points. Déjà, les calculs d'intersection sont très lourds, ils demandent beaucoup de calculs et d'opérations arithmétiques, plus que pour l'équivalent en rastérisation. Et ils se parallélisent mal. Le moteur de jeu doit d'abord lancer les rayons primaires, puis les rayons secondaires qui en découlent, puis les rayons de la troisième passe, etc. Au final, cela se parallélise assez mal, car il y a un ordre de traitement des rayons (primaires, puis secondaires, puis...), alors que la rastérisation traite chaque triangle/pixel/source de lumière de manière indépendante. Ensuite, le lancer de rayon n'est pas économe niveau accès mémoire. Ce qu'on économise avec l’absence de tampon de profondeur et l'absence de ''shadowmaps'', on le perd au niveau de la BVH et des accès aux textures. La BVH est notamment un énorme problème. Les BVH étant ce que les programmeurs connaissent sous le nom d'arbre (binaire ou k-tree), elles dispersent les données en mémoire, alors que les GPU et CPU modernes préfèrent des données consécutives en RAM. Leur hiérarchie mémoire est beaucoup plus efficace pour accéder à des données proches en mémoire qu'à des données dispersées et il n'y a pas grand chose à faire pour changer la donne. La traversée d'une BVH se fait avec des accès en mémoire vidéo complétement désorganisés, là où le rendu 3D a des accès plus linéaires qui permettent d'utiliser des mémoires caches. ==Le matériel pour accélérer le lancer de rayons== En théorie, il est possible d'utiliser des ''shaders'' pour effectuer du lancer de rayons, mais la technique n'est pas très performante. Il vaut mieux utiliser du hardware dédié. Heureusement, cela ne demande pas beaucoup de changements à un GPU usuel. Le lancer de rayons se différencie peu de la rastérisationla différence principale étant que l'étape de rastérisation est remplacée par une étape de lancer de rayons. Les deux types de rendu ont besoin de calculer la géométrie, d'appliquer des textures et de faire des calculs d'éclairage. Sachant cela, les GPU actuels ont juste besoin d'ajouter des circuits dédiés au lancer de rayons, à savoir des unités de génération des rayons et des unités pour les calculs d'intersection. ===Les circuits spécialisés pour les calculs liés aux rayons=== Toute la subtilité du lancer de rayons est de générer les rayons et de déterminer quels triangles ils intersectent. La seule difficulté est de gérer la transparence, mais aussi et surtout la traversée de la BVH. En soit, générer les rayons et déterminer leurs intersections n'est pas compliqué. Il existe des algorithmes basés sur du calcul vectoriel pour déterminer si intersection il y a et quelles sont ses coordonnées. C'est toute la partie de parcours de la BVH qui est plus compliquée à implémenter : faire du ''pointer chasing'' en hardware n'est pas facile. Et cela se ressent quand on étudie comment les GPU récents gèrent le lancer de rayons. Ils utilisent des shaders dédiés, qui communiquent avec une '''unité de lancer de rayon''' dédiée à la traversée de la BVH. Le terme anglais est ''Ray-tracing Unit'', ce qui fait que nous utiliseront l'abréviation RTU pour la désigner. Les shaders spécialisés sont des '''shaders de lancer de rayon''' et il y en a deux types. * Les '''''shaders'' de génération de rayon''' s'occupent de générer les rayons * Les '''''shaders'' de ''Hit/miss''''' s'occupent de faire tous les calculs une fois qu'une intersection est détectée. Le processus de rendu en lancer de rayon sur ces GPU est le suivant. Les shaders de génération de rayon génèrent les rayons lancés, qu'ils envoient à la RTU. La RTU parcours la BVH et effectue les calculs d'intersection. Si la RTU détecte une intersection, elle lance l'exécution d'un ''shader'' de ''Hit/miss'' sur les processeurs de ''shaders''. Ce dernier finit le travail, mais il peut aussi commander la génération de rayons secondaires ou d'ombrage. Suivant la nature de la surface (opaque, transparente, réfléchissante, mate, autre), ils décident s'il faut ou non émettre un rayon secondaire, et commandent les shaders de génération de rayon si besoin. [[File:Implementation hardware du raytracing.png|centre|vignette|upright=2|Implémentation hardware du raytracing]] ===La RTU : la traversée des structures d'accélérations et des BVH=== Lorsqu'un processeur de shader fait appel à la RTU, il lui envoie un rayon encodé d'une manière ou d'une autre, potentiellement différente d'un GPU à l'autre. Toujours est-il que les informations sur ce rayon sont mémorisées dans des registres à l'intérieur de la RTU. Ce n'est que quand le rayon quitte la RTU que ces registres sont réinitialisés pour laisser la place à un autre rayon. Il y a donc un ou plusieurs '''registres de rayon''' intégrés à la RTU. La RTU contient beaucoup d''''unités de calculs d'intersections''', des circuits de calcul qui font les calculs d'intersection. Il est possible de tester un grand nombre d'intersections de triangles en parallèles, chacun dans une unité de calcul séparée. L'algorithme de lancer de rayons se parallélise donc, au moins un minimum, et la RTU en profite. En soi, les circuits de détection des intersections sont très simples et se résument à un paquet de circuits de calcul (addition, multiplications, division, autres), connectés les uns aux autres. Il y a assez peu à dire dessus. Mais les autres circuits sont très intéressants à étudier. Il y a deux types d'intersections à calculer : les intersections avec les volumes englobants, les intersections avec les triangles. Volumes englobants et triangles ne sont pas encodés de la même manière en mémoire vidéo, ce qui fait que les calculs à faire ne sont pas exactement les mêmes. Et c'est normal : il y a une différence entre un pavé pour le volume englobant et trois sommets/vecteurs pour un triangle. La RTU des GPU Intel, et vraisemblablement celle des autres GPU, utilise des circuits de calcul différents pour les deux. Elle incorpore plus de circuits pour les intersections avec les volumes englobants, que de circuits pour les intersections avec un triangle. Il faut dire que lors de la traversée d'une BVH, il y a une intersection avec un triangle par rayon, mais plusieurs pour les volumes englobants. L'intérieur de la RTU contient aussi de quoi séquencer les accès mémoire nécessaires pour parcourir la BVH. Traverser un BVH demande de tester l'intersection avec un volume englobant, puis de décider s'il faut passer au suivant, et rebelotte. Une fois que la RTU tombe sur un triangle, elle l'envoie aux unités de calcul d'intersection dédiées aux triangles. Les RTU intègrent des '''caches de BVH''', qui mémorisent des portions de la BVH au cas où celles-ci seraient retraversées plusieurs fois de suite par des rayons consécutifs. La taille de ce cache est de l'ordre du kilo-octet ou plus. Pour donner des exemples, les GPU Intel d'architecture Battlemage ont un cache de BVH de 16 Kilo-octets, soit le double comparé aux GPU antérieurs. Le cache de BVH est très fortement lié à la RTU et n'est pas accesible par l'unité de texture, les processeurs de ''shader'' ou autres. [[File:Raytracing Unit.png|centre|vignette|upright=2|Raytracing Unit]] Pour diminuer l’impact sur les performances, les cartes graphiques modernes incorporent des circuits de tri pour regrouper les rayons cohérents, ceux qui vont dans la même direction et proviennent de triangles proches. Ce afin d'implémenter les optimisations vues plus haut. ===La génération des structures d'accélération et BVH=== La plupart des cartes graphiques ne peuvent pas générer les BVH d'elles-mêmes. Pareil pour les autres structures d'accélération. Elles sont calculées par le processeur, stockées en mémoire RAM, puis copiées dans la mémoire vidéo avant le démarrage du rendu 3D. Cependant, quelques rares cartes spécialement dédiées au lancer de rayons incorporent des circuits pour générer les BVH/AS. Elles lisent le tampon de sommet envoyé par le processeur, puis génèrent les BVH complémentaires. L'unité de génération des structures d'accélération est complétement séparée des autres unités. Un exemple d'architecture de ce type est l'architecture Raycore, dont nous parlerons dans les sections suivantes. Cette unité peut aussi modifier les BVH à la volée, si jamais la scène 3D change. Par exemple, si un objet change de place, comme un NPC qui se déplace, il faut reconstruire le BVH. Les cartes graphiques récentes évitent de reconstruire le BVH de zéro, mais se contentent de modifier ce qui a changé dans le BVH. Les performances en sont nettement meilleures. ==Un historique rapide des cartes graphiques dédiées au lancer de rayon== Les premières cartes accélératrices de lancer de rayons sont assez anciennes et datent des années 80-90. Leur évolution a plus ou moins suivi la même évolution que celle des cartes graphiques usuelles, sauf que très peu de cartes pour le lancer de rayons ont été produites. Par même évolution, on veut dire que les cartes graphiques pour le lancer de rayon ont commencé par tester deux solutions évidentes et extrêmes : les cartes basées sur des circuits programmables d'un côté, non programmables de l'autre. La première carte pour le lancer de rayons était la TigerShark, et elle était tout simplement composée de plusieurs processeurs et de la mémoire sur une carte PCI. Elle ne faisait qu'accélérer le calcul des intersections, rien de plus. Les autres cartes effectuaient du rendu 3D par voxel, un type de rendu 3D assez spécial que nous n'avons pas abordé jusqu'à présent, dont l'algorithme de lancer de rayons n'était qu'une petite partie du rendu. Elles étaient destinées au marché scientifique, industriel et médical. On peut notamment citer la VolumePro et la VIZARD et II. Les deux étaient des cartes pour bus PCI qui ne faisaient que du ''raycasting'' et n'utilisaient pas de rayons d'ombrage. Le ''raycasting'' était exécuté sur un processeur dédié sur la VIZARD II, sur un circuit fixe implémenté par un FPGA sur la Volume Pro Voici différents papiers académiques qui décrivent l'architecture de ces cartes accélératrices : * [https://citeseerx.ist.psu.edu/document?repid=rep1&type=pdf&doi=ccda3712ba0e6575cd08025f3bb920de46201cac The VolumePro Real-Time Ray-Casting System]. * [https://www.researchgate.net/publication/234829060_VIZARD_II_A_reconfigurable_interactive_volume_rendering_system VIZARD II: A reconfigurable interactive volume rendering system] La puce SaarCOR (Saarbrücken's Coherence Optimized Ray Tracer) et bien plus tard par la carte Raycore, étaient deux cartes réelement dédiées au raycasting pur, sans usage de voxels, et étaient basées sur des FPGA. Elles contenaient uniquement des circuits pour accélérer le lancer de rayon proprement dit, à savoir la génération des rayons, les calculs d'intersection, pas plus. Tout le reste, à savoir le rendu de la géométrie, le placage de textures, les ''shaders'' et autres, étaient effectués par le processeur. Voici des papiers académiques sur leur architecture : * [https://gamma.cs.unc.edu/SATO/Raycore/raycore.pdf RayCore: A ray-tracing hardware architecture for mobile devices]. Le successeur de la SaarCOR, le Ray Processing Unit (RPU), était une carte hybride : c'était une SaarCOR basée sur des circuits fixes, qui gagna quelques possibilités de programmation. Elle ajoutait des processeurs de ''shaders'' aux circuits fixes spécialisés pour le lancer de rayons. Les autres cartes du genre faisaient pareil, et on peut citer les cartes ART, les cartes CausticOne, Caustic Professional's R2500 et R2100. Les cartes 3D modernes permettent de faire à la fois de la rasterisation et du lancer de rayons. Pour cela, les GPU récents incorporent des unités pour effectuer des calculs d'intersection, qui sont réalisés dans des circuits spécialisés. Elles sont appelées des RT Cores sur les cartes NVIDIA, mais les cartes AMD et Intel ont leur équivalent, idem chez leurs concurrents de chez Imagination technologies, ainsi que sur certains GPU destinés au marché mobile. Peu de choses sont certaines sur ces unités, mais il semblerait qu'il s'agisse d'unités de textures modifiées. De ce qu'on en sait, certains GPU utilisent des unités pour calculer les intersections avec les triangles, et d'autres unités séparées pour calculer les intersections avec les volumes englobants. La raison est que, comme dit plus haut, les algorithmes de calcul d'intersection sont différents dans les deux cas. Les algorithmes utilisés ne sont pas connus pour toutes les cartes graphiques. On sait que les GPU Wizard de Imagination technology utilisaient des tests AABB de Plücker. Ils utilisaient 15 circuits de multiplication, 9 additionneurs-soustracteurs, quelques circuits pour tester la présence dans un intervalle, des circuits de normalisation et arrondi. L'algorithme pour leurs GPU d'architecture Photon est disponible ici, pour les curieux : [https://www.highperformancegraphics.org/slides23/2023-06-_HPG_IMG_RayTracing_2.pdf]. <noinclude> {{NavChapitre | book=Les cartes graphiques | prev=Les écritures en VRAM hors ROPs | prevText=Les écritures en VRAM hors ROPs | next=L'antialiasing | prevNext=L'antialiasing }}{{autocat}} </noinclude> ngptnra4dlzgct3er7pekc8bxm1vje5 Les cartes graphiques/Le mode texte et le rendu en tiles 0 81132 765162 764863 2026-04-26T20:26:18Z Mewtow 31375 /* La table des caractères */ 765162 wikitext text/x-wiki Les toutes premières cartes d'affichage portaient le nom de cartes d'affichage en mode texte. Comme leur nom l'indique, elles sont spécifiquement conçues pour afficher du texte, pas des images. Elles étaient utilisées sur les ordinateurs personnels ou professionnels, qui n'avaient pas besoin d'afficher des graphismes, seulement des lignes de commandes, tableurs, traitements de textes rudimentaires, etc. Elles ont rapidement été remplacées par des cartes graphiques avec un ''framebuffer''. Il s'agit d'une avancée énorme, qui permet beaucoup plus de flexibilité dans l'affichage. L'existence de telles cartes en mode texte tient au fait que la mémoire vidéo était chère et qu'on ne pouvait pas en mettre beaucoup dans une carte d'affichage ou dans une console de jeu. Aussi, les fabricants de cartes graphiques devaient ruser. La petitesse de la mémoire faisait qu'il n'y avait pas de ''framebuffer'' proprement dit. On a vu au chapitre précédent qu'il existe des techniques de rendu 2D qui se passent de ''framebuffer'', hé bien les premières cartes d'affichage les utilisaient sous une forme détournée. ==Le rendu en mode texte== En '''mode texte''', l'écran était découpé en carré ou en rectangles de taille fixe, contenant chacun un caractère. Les caractères affichables sont des lettres, des chiffres, ou des symboles courants, même si des caractères spéciaux sont disponibles. La carte d'affichage traitait les caractères comme un tout et il était impossible de modifier des pixels individuellement. Ceux-ci sont encodés dans un jeu de caractère spécifique (ASCII, ISO-8859, etc.), qui est généralement l'ASCII. [[File:VGAText3.png|centre|vignette|upright=1.5|]] Tous les caractères sont des images de taille fixe, que ce soit en largeur ou en hauteur. Par exemple, un caractère peut faire 8 pixels de haut et 8 pixels de large, sur les écrans cathodiques. Sur les écrans LCD, les pixels sont carrés et les caractères font 16 pixels de haut par 8 de large pour avoir un aspect rectangulaire. [[File:VGA text sample animation.gif|vignette|Illustration du mode texte.]] Les ''attributs des caractères'' sont des informations qui indiquent si le caractère clignote, sa couleur, sa luminosité, si le caractère doit être souligné, etc. Une gestion minimale de la couleur est parfois présente. Le tout est mémorisé dans un octet, à la suite du code ASCII du caractère. Le mode texte est toujours présent dans nos cartes graphiques actuelles et est encore utilisé par le BIOS, ce qui lui donne cet aspect désuet et moche des plus inimitables. ===L'architecture d'une carte d'affichage en mode texte=== Paradoxalement, les cartes d'affichage en mode texte sont de loin les moins intuitives et elles sont plus complexes que les cartes d'affichage en mode graphique. Les limitations de la technologie de l'époque rendaient plus adaptées les cartes en mode texte, notamment les limitations en termes de mémoire vidéo. La faible taille de la mémoire rendait impossible l'usage d'un ''framebuffer'' proprement dit, ce qui fait que les ingénieurs ont utilisé un moyen de contournement, qui a donné naissance aux cartes graphiques en mode texte. Par la suite, avec l'amélioration de la technologie des mémoires, les cartes d'affichage avec un ''framebuffer'' sont apparues et ont remplacé les complexes cartes d'affichage en mode texte. Les cartes d'affichage en mode texte et avec ''framebuffer'' ont une architecture assez similaire. Pour rappel, une carte graphique avec un ''framebuffer'' est composée de plusieurs composants : un circuit d’interfaçage avec le bus, un circuit de contrôle appelé le CRTC, une mémoire vidéo, un DAC qui convertit les pixels en signal analogique, et quelques circuits annexes. [[File:Architecture interne d'une carte d'affichage en mode graphique.png|centre|vignette|upright=2.0|Architecture interne d'une carte d'affichage en mode graphique]] Une carte en mode texte a les mêmes composants, avec quelques modifications. Le '''tampon de texte''' (''text buffer'') est la mémoire vidéo. Dans celle-ci, les caractères à afficher sont placés les uns à la suite des autres. Chaque caractère est stocké en mémoire avec deux octets : un octet pour le code ASCII, suivi d'un octet pour les attributs. L'avantage du mode texte est qu'il utilise très peu de mémoire vidéo : on ne code que les caractères et non des pixels indépendants, et un caractère correspond à beaucoup de pixels. Le CRTC est modifié de manière tenir compte de l'organisation du tampon de texte. De plus, quelques circuits sont ensuite utilisés pour faire la conversion texte-image, comme on le verra plus bas. Le circuit de conversion texte-pixel est une petite mémoire ROM appelée la '''mémoire de caractère''', qui mémorise, pour chaque caractère, sa représentation sous forme de pixels. La carte graphique contient aussi un circuit chargé de gérer les attributs des caractères : l'ATC (''Attribute Controller''), aussi appelé le '''contrôleur d'attributs'''. Il est situé juste en aval du tampon de texte. [[File:Architecture interne d'une carte d'affichage en mode texte.png|centre|vignette|upright=2.0|Architecture interne d'une carte d'affichage en mode texte]] ===La table des caractères=== Les images de chaque caractère sont mémorisées dans une mémoire : la '''table des caractères''', aussi appelée mémoire des caractères dans les schémas au-dessus. Dans cette mémoire, chaque caractère est représenté par une matrice de pixels, avec un bit par pixel. Certaines cartes graphiques permettent à l'utilisateur de créer ses propres caractères en modifiant cette table, ce qui en fait une mémoire ROM/EEPROM ou RAM. On pourrait croire que la table des caractères telle que si l'on envoie le code ASCII sur l'entrée d'adresse, on récupère en sortie l'image du caractère associé. Mais cette solution simple est irréaliste : un simple caractère monochrome de 8 pixels de large et de 8 pixels de haut demanderait près de 64 pixels en sortie, soit facilement plusieurs centaines de bits, ce qui est impraticable, surtout pour les mémoires de l'époque. En réalité, la mémoire de caractère a une sortie de 1 pixel, le pixel en question étant pris dans l'image du caractère sélectionné. L'entrée d'adresse s'obtient alors en concaténant trois informations : le code ASCII pour sélectionner le caractère, le numéro de la ligne et le numéro de la colonne pour sélectionner le bit dans l'image du caractère. Les deux numéros sont fournis par le CRTC, comme on le verra plus bas. ===Le CRTC sur une carte en mode texte=== Le mode texte impose de modifier le CRTC de manière à ce qu'il adresse le tampon de texte correctement. Il contient toujours deux compteurs, pour localiser la ligne et la colonne du pixel à afficher, mais doit transformer cela en adresse de caractère. Le CRTC doit sélectionner le caractère à afficher, puis sélectionner le pixel dans celui-ci, ce qui demande la collaboration de la mémoire de caractères et le tampon de texte. Le CRTC va déduire à quel caractère correspond le pixel choisit, et le récupérer dans le tampon de texte. Là, le code du caractère est envoyé à la mémoire de caractère, et le CRTC fournit de quoi sélectionner le numéro de ligne et le numéro de colonne. Le pixel récupéré dans la mémoire de caractère est alors envoyé à l'écran. Concrètement, les calculs à faire pour déterminer le caractère et pour trouver les numéros de ligne/colonne sont très simples, sans compter que les deux sont liés. Prenons par exemple un écran dont les caractères font tous 12 pixels de large et 8 pixels de haut. Le pixel de coordonnées X (largeur) et Y (hauteur) correspond au caractère de position X/12 et Y/8. Le reste de la première division donne la position de la colonne pour la mémoire de caractère, alors que le reste de la seconde division donne le numéro de ligne. Si les caractères ont une largeur et une hauteur qui sont des puissances de deux, les divisions se simplifient : la position du caractère dans la mémoire se calcule alors à partir des bits de poids forts des compteurs X et Y, alors que les bits de poids faible permettent de donner le numéro de ligne et de colonne pour la mémoire de caractère. Dans le diagramme ci-dessus, les 3 bits de poids faible des registres X et Y de balayage des pixels servent à adresser le pixel parmi les 8x8 du bloc correspondant au caractère à afficher. L'index de ce caractère est lu dans le tampon de texte, adressé par les bits restant des registres X et Y. <noinclude>[[File:Character generator scheme.svg|centre|vignette|upright=2.0|Balayage des pixels par le CRTC pour un mode texte avec des caractères de 8x8 pixels.]]</noinclude> <noinclude>[[File:MC6845.svg|vignette|Motorola 6845]]</noinclude> Un exemple de CRTC est le ''Motorola 6845''. Ce VDP génère l'adresse à lire dans la mémoire vidéo, ainsi que les signaux de synchronisation horizontale et verticale, mais ne fait pas grand-chose d'autre. Lire la mémoire vidéo, extraire les pixels et envoyer le tout à l'écran n'est pas de son ressort. Il gère le mode texte uniquement, mais on peut supporter le mode graphique en trichant. Il supporte le mode entrelacé et le mode non-entrelacé, et est compatible aussi bien avec les moniteurs en PAL qu'en NTSC. Il contient 18 registres dont le contenu permet de configurer le VDP, pour configurer la résolution, la fréquence d'affichage, et d'autres choses encore. [[File:Motorola MC6845P.jpg|centre|vignette|Motorola MC6845P]] D'autres CRTC plus évolués gèrent à la fois le mode texte et le mode graphique, on peut les configurer de manière à choisir lequel utiliser. Il est ainsi possible de prendre un VDP CRTC pour le mettre avec une mémoire assez petite pour gérer uniquement des graphiques en mode texte. Ou au contraire, de prendre un VDP CRTC et de le combiner avec une mémoire importante pour l'utiliser comme ''framebuffer''. ===Le défilement du texte=== Faire défiler du texte est une opération très courante. Concrètement, cela fait descendre d'une ou plusieurs lignes dans le texte, ce qui demande de bouger tout le texte à l'écran. Le défilement ne se fait pas ligne de pixel par ligne de pixel, mais ligne par ligne, voire par paquets de plusieurs lignes. Par exemple, si un caractère de texte fait 8 pixels de haut, alors on saute les lignes par paquets de 8. Il s'agit presque toujours de défilement vertical. Le texte est généralement défilé de haut en bas, verticalement, le défilement horizontal étant plus rare. Même sur les écrans de l'époque, qui avaient des colonnes limitées à 80/100 caractères, le texte était conçu de manière à ce que les lignes de texte ne débordent pas de l'écran. Aussi, les optimisations qui nous intéressent sont surtout les optimisations du défilement vertical. Défiler du texte est une opération très courante qui gagne à être optimisé au niveau de la carte graphique. Les toutes premières cartes graphiques MBA (monochromes) et CGA n'incorporaient pas de défilement vertical optimisé, mais les cartes suivantes, EGA et VGA, le faisaient. Les optimisations en question sont nombreuses, mais nous allons en voir deux. La première optimisation que nous allons voir réutilise les techniques vues dans le chapitre sur le rendu 2D. Pour cela, il utilise un tampon de texte organisé en lignes de texte consécutives. Le tampon de texte mémorise plus de lignes que ce qui est affiché à l'écran. Ce qui est visible à l'écran est une portion du tampon de texte, appelée le ''viewport''. Ainsi, les lignes à afficher au-dessus ou en dessous du ''viewport'' sont déjà en mémoire vidéo. Il y a juste à fournir un registre qui pointe vers la position du ''viewport'' dans le tampon de texte, ce qui permet de faire bouger le ''viewport'' à volonté. Une autre solution, plus économe en RAM, fait usage d'un VDC à co-processeur. Pour rappel, ce sont des VDC qui incorporent un processeur qui exécute un programme d'affichage. Le programme, appelé la ''display list'', afficher une image à l'écran, ou du texte, ou tout ce qu'il faut afficher. Chaque instruction de la ''display list'' dit quoi afficher sur une ligne à l'écran. Ici, elle dit quoi afficher sur une ligne de texte, une ligne de caractère, et non une ligne de pixels comme dans les chapitres précédents. Un exemple de VDC à co-processeur en mode texte est celui des '''ordinateurs Amstrad PCW'''. Il s'agissait d'ordinateurs qui ne géraient que le mode texte, ils ne pouvaient pas afficher d'image pixel par pixel, ils n'avaient pas de ''framebuffer''. Le texte à afficher à l'écran n'était pas stocké dans un tableau unique, ligne par ligne, dans l'ordre de rendu, comme c'est le cas sur les autres cartes en mode texte. A la place, chaque ligne était stocké en mémoire séparément les unes des autres, ou presque. L'affichage était gouverné par une ''display list'' qui disait quelle ligne afficher, et dans quel ordre. La ''display list'' était une liste d'adresses, chacun pointant vers le début d'une ligne en mémoire vidéo. Toutes les lignes faisaient la même taille, ce qui fait que la ''display list'' avait juste à encoder l'adresse de départ de la ligne pour l'afficher correctement. Le processeur du VDC lisait la ''display list'' adresse par adresse et rendait les lignes dans l'ordre précisé par la ''display list''. Le défilement était alors simple à implémenter : il suffisait de modifier le contenu de la ''display list''. Par exemple, pour défiler d'une ligne vers le cas, on décalait son contenu de la ''display list'' d'un cran et on modifiait la ligne la plus en haut. L'avantage est que modifier une ''display list'' est plus rapide que de faire défiler l'ensemble du ''text buffer''. La ''display list'' était stockée dans une mémoire RAM spécialisée de 512 octets, ce qui permettait de stocker 256 adresses de 16 bits chacune. La ''display list'' avait 256 lignes, ce qui collait exactement à la résolution de 720 par 256 pixels de l'Amstrad PCW. LA RAM qui mémorisait la ''display list'' s'appelait la '''''roller RAM'''''. Elle était utilisée par le processeur 280 de la machine pour gérer le rendu de l'affichage. Le VDC utilisé était en effet très simple et se résumait sans doute à un vulgaire CRTC en mode texte. ==Le rendu en mode semi-graphique== [[File:Level 1 teletext test.png|vignette|Rendu d'une image en mode semi-graphique.]] Le rendu en mode texte ne permet en théorie de n'afficher que du texte. Cependant, il est possible d'émuler un rendu graphique à partir du mode texte, en trichant un petit peu. On a alors un mode de rendu appelé '''mode semi-graphique'''. Il y en a deux sous-types, appelés ''rendu graphique en bloc'' et ''pseudo-graphique'', qui seront détaillés ci-dessous. Les mode semi-graphiques sont en soi des techniques logicielles, qui ne demandent pas de support particulier de la part du matériel. Ils servent cependant d'introduction propédeutique au rendu en motifs qui sera vu à la fin du chapitre. La triche n'est possible que sur les cartes en mode texte sur lesquelles les caractères sont configurables, c’est-à-dire qu'on peut préciser à quoi ressemblent les caractères. Sur de telles cartes en mode texte, on peut fournir un dessin rectangulaire de quelques pixels de côté à la carte graphique et lui dire : ceci est le caractère numéro 40. L'idée est de remplacer les caractères par des dessins basiques, des '''motifs''', qui sont assemblés pour fabriquer des "sprites", qui sont eux-même assemblés pour former l'image finale. ==Le rendu à motif (''tiles'')== Le '''rendu en motifs''' est un proche cousin du mode texte. Il n'est cependant pas spécialisé pour du texte, mais permet de compresser des images complètes. Il a été utilisé sur des consoles de jeu, afin d'outrepasser les contraintes en mémoire RAM. les consoles 8 bits avaient peu de mémoire et ne pouvaient pas utiliser de ''framebuffer'', même en utilisant la technique de la palette indicée. Elles ne pouvaient pas non plus utiliser de tampon de ligne, car le processeur n'était pas assez puissant pour. Alors, elles utilisaient le rendu en motifs pour rendre des images avec peu de mémoire vidéo. ===Le stockage des ''sprites'' et de l’arrière-plan : les tiles=== [[File:Tile set.png|vignette|Tile set de Ultima VI.]] Le rendu en motifs force une certaine redondance à l'intérieur de l'arrière-plan et des ''sprites''. L'idée est que les ''sprites'' et l'arrière-plan sont fabriqués à partir de motifs, aussi appelés ''tiles''. Concrètement, ce sont des dessins carrés de 8, 16, 32 pixels de côtés, qui sont assemblés pour fabriquer un ''sprite'' ou l'arrière-plan. L'ensemble des motifs est mémorisée dans un fichier unique, appelé le ''tile set'', le ''tilemap'', ou encore la '''table des motifs'''. La table des motifs est placée dans la cartouche de jeu, souvent dans une mémoire ROM dédiée. Ci-contre, vous voyez la table des motifs du jeu Ultima VI. Les motifs sont numérotés, un numéro identifiant un motif parmi toutes les autres. L'image ne mémorise pas des pixels, mais des numéros de motifs. Le gain est assez appréciable : avec des motifs de 8 pixels de côté, au lieu de stocker X * Y pixels, on stocke X/8 * Y/8 numéros de motifs par image. La mémoire vidéo n'est donc pas un ''framebuffer'', mais un '''tampon de motifs'''. L'image à afficher à l'écran est reconstituée par la carte graphique, lors de l'affichage, en plusieurs étapes. Un avantage est que les motifs peuvent être utilisés en plusieurs endroits, ce qui garantit une certaine redondance. L'arrière-plan, qui est généralement l'image la plus redondante. Par exemple, un ciel est composé de motifs bleus identiques. Idem quand il faut rendre plusieurs petits ennemis identiques à l'écran : on n'utilise qu'un seul motif pour tous les ennemis. Pareil si un motifs est utilisé dans plusieurs ''sprites'', il n'est stockée qu'une seule fois. Il s'agit donc d'une forme de compression d'image qui profite d'une certaine redondance. Ajoutons à cela que les numéros de motifs prennent moins de place que la couleur d'un pixel, et les gains sont encore meilleurs. La consommation mémoire est déportée de l'image vers la mémoire qui stocke les motifs, une mémoire ROM intégrée dans la cartouche de jeu.''' '''. ===L'architecture d'une carte d'affichage en rendu à motifs=== Pour les consoles de 2ème, 3ème et 4ème génération, l'usage de motifs était obligatoire et tous les jeux vidéo utilisaient cette technique. Les cartes graphiques des consoles de jeux de cette époque étaient conçues pour gérer les motifs. Le matériel affichait l'image finale ligne par ligne, pixel par pixel comme le ferait un CRTC. Sauf qu'il détermine : dans quelle motif se trouve le pixel à afficher, où se trouve le pixel dans le motif (quelle ligne, quelle colonne). Une fois cela fait, le VDC accède à la mémoire vidéo pour récupérer le numéro de motif. Une fois le numéro de motif connu, il lit la table des potifs en mémoire ROM, sur la cartouche. Puis, il sélectionne le pixel à afficher dans ce motif, et l'envoie à la palette indicée, puis à l'écran. [[File:VDC à rendu à motif, sans gestion des sprites.png|centre|vignette|upright=2|VDC à rendu à motif, sans gestion des sprites]] Les motifs sont des équivalents des caractères dans le mode texte. Un motif peut être vu comme une sorte de super-caractère. Une carte d'affichage en mode texte et une carte en rendu à motifs sont d'ailleurs très similaires. Le tampon de motifs est l'équivalent pour le rendu à motifs du tampon de texte en mode texte. La table des motifs est l'équivalent de la table des caractères, les deux convertissent un caractère/''tile'' en pixels. La méthode d'adressage est fortement similaire, l'utilisation l'est aussi. La seule différence est leur contenu, la table des caractères stockant des caractères, la table des motifs stockant des motifs graphiques. Une optimisation permettait de lire certains motifs dans les deux sens à l'horizontale, de faire une sorte d'opération miroir. Ce faisant, on pouvait créer un objet symétrique en mémorisant seulement un motif. Par exemple, la moitié droite est générée par une opération miroir sur la partie gauche. Mais cette optimisation était assez rare, car elle demandait d'ajouter des circuits dans un environnement où le moindre transistor était cher. De plus, les objets symétriques sont généralement assez rares. Les ''sprites'' sont presque toujours gérés avec le rendu à motifs, pour des raisons de performance. Les ''sprites'' les plus simples sont un seul motif, mais les autres ''sprites'' sont formés en assemblant plusieurs motifs. Typiquement, un ''sprite'' prend de 2 à 4 motifs. Par exemple, un ''sprite'' de 16 pixels de haut et 8 pixels de large est composé de deux motifs de 8 pixels de côté. Pour cela, les registres pour les ''sprites'' mémorisent des numéros de motif, pas des pixels. [[File:Carte 2D avec un rendu en tile.png|centre|vignette|upright=2|Carte 2D avec un rendu en tile]] ===Le défilement avec des motifs=== Le défilement se marie assez mal avec un rendu à base de motifs. La solution la plus simple fait du défilement motif par motif, par sauts de 8 pixels. L'implémentation est la même qu'avec le défilement, à savoir qu'on utilise un ''viewport'' matériel. La différence est que la mémoire vidéo contient des motifs et non des pixels, idem pour le ''viewport''. Le résultat à l'écran n'est pas fluide du tout, il donne un défilement saccadé et désagréable. Pour corriger cela, il a existé des techniques pour implémenter du défilement du défilement pixel par pixel avec un rendu à motifs. Un défilement de ce type est appelé un '''défilement fluide''', ou encore du '''défilement pixel par pixel'''. La première implémentation du défilement fluide avec des motifs était purement logicielle, le VDC faisait lui du défilement motif par motif. Sur PC, la première implémentation a été trouvée par John Carmack, le programmeur derrière les moteurs des jeux IDSoftware comme DOOM, Quake, Wolfenstein 3D, etc. Il a inventé la technique de l'''Adaptive tile refresh'' qui permet justement d'avoir un défilement fluide sur des cartes sans gestion hardware du défilement. D'autres solutions logicielles similaires existaient sur console. Elles utilisaient 8 copies de chaque motif, chacun étant décalé d'un pixel. Le motif adéquat était choisi suivant un décalage dépendant du défilement. Mais il est possible de faire du défilement pixel par pixel avec un rendu à motifs, en ajoutant du hardware spécifique au VDC. Elles étaient implémentées sur les consoles de 4ème génération et antérieures, mais pas sur les anciens PC. Une des toute première console à gérer le défilement pixel par pixel était l'Intellivision, une des toutes premières consoles, précisément une console de deuxième génération. Elle avait des motifs de 8 pixels de côté, et permettait de défiler au pixel près. Pour cela, elle disposait d'un '''registre de décalage''' contenant une valeur allant de 0 à 7, qui indiquait de combien de pixels il fallait décaler l'image sur l'écran. Pour décaler d'une valeur plus grande, il fallait recourir au logiciel. Le logiciel devait alors faire défiler l'image en la déplaçant en mémoire vidéo. Il s'agissait donc d'une implémentation partielle du défilement avec des motifs. Le logiciel faisait du défilement motif par motif, et pouvait finir le travail en ajustant le tout pour obtenir un défilement pixel par pixel. Les consoles suivantes géraient le défilement pixel par pixel d'une manière plus optimisée, en gérant à la fois le registre de décalage et le défilement motif par motif. Le défilement pixel par pixel était réalisé en faisant un défilement motif par motif, puis en finissant le travail avec un registre de décalage. Le défilement motif par motif était géré comme expliqué ci-dessus, avec un ''viewport''. ===Les glitchs graphiques sur les bords de l'écran=== Malgré leur support matériel, il arrive que le défilement au pixel près cause des glitchs graphiques sur les bords de l'écran. La raison est qu'avec un défilement par pixel, les motifs aux bords de l'écran sont partiellement affichés. Par exemple, un motif sur le bord gauche ait ses pixels gauche en-dehors de l'écran, ses pixels droits dans l'écran. Idem mais sur le bord droit : un motif a ses pixels de gauche dans l'écran, ceux de droite en-dehors. Le problème est que de tels motifs peuvent ne pas être affichés si le VDC ne le permet pas. En général, les motifs partiellement en-dehors de l'écran ne sont pas affichés, ce qui cause des glitchs graphiques. Les VDC de l'époque ne pouvaient pas afficher un motif si ses coordonnées sont en-dehors de l'écran. En général, la coordonnée d'un motif est définie par rapport à une origine placée sur le pixel le plus en haut et le plus à gauche du motif. Dans ce cas, les motifs à gauche de l'écran ont une origine qui est en-dehors de l'écran, même si quelques pixels de droite sont affichés. Le motif n'est alors pas affiché. Ces pixels à droite disparaissent, ce qui fait qu'entre 0 et 7 pixels disparaissent pour un motif de 8 pixel de largeur, entre 0 et 15 pixels s'il a une largeur de 16 pixels, etc. Idem avec le défilement vertical, mais pour les motifs tout en haut de l'écran. : Si l'origine du motif est définie sur le bord droit, c'est les motifs à droite de l'écran qui disparaissent. Il y a la même chose avec les ''sprites''. Si leur origine est en-dehors de l'écran, mais que le reste du ''sprite'' est affiché, ils ne sont pas affichés. Une solution pour éviter les problèmes est de les faire rentrer d'un seul côté de l'écran, typiquement à droite si leur origine est définie à gauche. Si le défilement va dans le bon sens, aucun glitch graphique ne se manifeste. Pour cacher ces glitchs graphiques, il est possible d'ajouter des bandes noires sur les deux côtés de l'écran. Les bandes noires sont verticales et/ou horizontales, elle ont une largeur égale à un motif. Le VDC peut gérer nativement ces bandes noires, en matériel. Pour cela, il réduit simplement la résolution pour couper les bords de l'écran. {{NavChapitre | book=Les cartes graphiques | prev=Les cartes accélératrices 2D | prevText=Les cartes accélératrices 2D | next=Les accélérateurs de scanline | nextText=Les accélérateurs de scanline }} {{autocat}} 0dxqd3ybotdcy3w005kjokzda1nuamy 765163 765162 2026-04-26T20:26:37Z Mewtow 31375 /* Le CRTC sur une carte en mode texte */ 765163 wikitext text/x-wiki Les toutes premières cartes d'affichage portaient le nom de cartes d'affichage en mode texte. Comme leur nom l'indique, elles sont spécifiquement conçues pour afficher du texte, pas des images. Elles étaient utilisées sur les ordinateurs personnels ou professionnels, qui n'avaient pas besoin d'afficher des graphismes, seulement des lignes de commandes, tableurs, traitements de textes rudimentaires, etc. Elles ont rapidement été remplacées par des cartes graphiques avec un ''framebuffer''. Il s'agit d'une avancée énorme, qui permet beaucoup plus de flexibilité dans l'affichage. L'existence de telles cartes en mode texte tient au fait que la mémoire vidéo était chère et qu'on ne pouvait pas en mettre beaucoup dans une carte d'affichage ou dans une console de jeu. Aussi, les fabricants de cartes graphiques devaient ruser. La petitesse de la mémoire faisait qu'il n'y avait pas de ''framebuffer'' proprement dit. On a vu au chapitre précédent qu'il existe des techniques de rendu 2D qui se passent de ''framebuffer'', hé bien les premières cartes d'affichage les utilisaient sous une forme détournée. ==Le rendu en mode texte== En '''mode texte''', l'écran était découpé en carré ou en rectangles de taille fixe, contenant chacun un caractère. Les caractères affichables sont des lettres, des chiffres, ou des symboles courants, même si des caractères spéciaux sont disponibles. La carte d'affichage traitait les caractères comme un tout et il était impossible de modifier des pixels individuellement. Ceux-ci sont encodés dans un jeu de caractère spécifique (ASCII, ISO-8859, etc.), qui est généralement l'ASCII. [[File:VGAText3.png|centre|vignette|upright=1.5|]] Tous les caractères sont des images de taille fixe, que ce soit en largeur ou en hauteur. Par exemple, un caractère peut faire 8 pixels de haut et 8 pixels de large, sur les écrans cathodiques. Sur les écrans LCD, les pixels sont carrés et les caractères font 16 pixels de haut par 8 de large pour avoir un aspect rectangulaire. [[File:VGA text sample animation.gif|vignette|Illustration du mode texte.]] Les ''attributs des caractères'' sont des informations qui indiquent si le caractère clignote, sa couleur, sa luminosité, si le caractère doit être souligné, etc. Une gestion minimale de la couleur est parfois présente. Le tout est mémorisé dans un octet, à la suite du code ASCII du caractère. Le mode texte est toujours présent dans nos cartes graphiques actuelles et est encore utilisé par le BIOS, ce qui lui donne cet aspect désuet et moche des plus inimitables. ===L'architecture d'une carte d'affichage en mode texte=== Paradoxalement, les cartes d'affichage en mode texte sont de loin les moins intuitives et elles sont plus complexes que les cartes d'affichage en mode graphique. Les limitations de la technologie de l'époque rendaient plus adaptées les cartes en mode texte, notamment les limitations en termes de mémoire vidéo. La faible taille de la mémoire rendait impossible l'usage d'un ''framebuffer'' proprement dit, ce qui fait que les ingénieurs ont utilisé un moyen de contournement, qui a donné naissance aux cartes graphiques en mode texte. Par la suite, avec l'amélioration de la technologie des mémoires, les cartes d'affichage avec un ''framebuffer'' sont apparues et ont remplacé les complexes cartes d'affichage en mode texte. Les cartes d'affichage en mode texte et avec ''framebuffer'' ont une architecture assez similaire. Pour rappel, une carte graphique avec un ''framebuffer'' est composée de plusieurs composants : un circuit d’interfaçage avec le bus, un circuit de contrôle appelé le CRTC, une mémoire vidéo, un DAC qui convertit les pixels en signal analogique, et quelques circuits annexes. [[File:Architecture interne d'une carte d'affichage en mode graphique.png|centre|vignette|upright=2.0|Architecture interne d'une carte d'affichage en mode graphique]] Une carte en mode texte a les mêmes composants, avec quelques modifications. Le '''tampon de texte''' (''text buffer'') est la mémoire vidéo. Dans celle-ci, les caractères à afficher sont placés les uns à la suite des autres. Chaque caractère est stocké en mémoire avec deux octets : un octet pour le code ASCII, suivi d'un octet pour les attributs. L'avantage du mode texte est qu'il utilise très peu de mémoire vidéo : on ne code que les caractères et non des pixels indépendants, et un caractère correspond à beaucoup de pixels. Le CRTC est modifié de manière tenir compte de l'organisation du tampon de texte. De plus, quelques circuits sont ensuite utilisés pour faire la conversion texte-image, comme on le verra plus bas. Le circuit de conversion texte-pixel est une petite mémoire ROM appelée la '''mémoire de caractère''', qui mémorise, pour chaque caractère, sa représentation sous forme de pixels. La carte graphique contient aussi un circuit chargé de gérer les attributs des caractères : l'ATC (''Attribute Controller''), aussi appelé le '''contrôleur d'attributs'''. Il est situé juste en aval du tampon de texte. [[File:Architecture interne d'une carte d'affichage en mode texte.png|centre|vignette|upright=2.0|Architecture interne d'une carte d'affichage en mode texte]] ===La table des caractères=== Les images de chaque caractère sont mémorisées dans une mémoire : la '''table des caractères''', aussi appelée mémoire des caractères dans les schémas au-dessus. Dans cette mémoire, chaque caractère est représenté par une matrice de pixels, avec un bit par pixel. Certaines cartes graphiques permettent à l'utilisateur de créer ses propres caractères en modifiant cette table, ce qui en fait une mémoire ROM/EEPROM ou RAM. On pourrait croire que la table des caractères telle que si l'on envoie le code ASCII sur l'entrée d'adresse, on récupère en sortie l'image du caractère associé. Mais cette solution simple est irréaliste : un simple caractère monochrome de 8 pixels de large et de 8 pixels de haut demanderait près de 64 pixels en sortie, soit facilement plusieurs centaines de bits, ce qui est impraticable, surtout pour les mémoires de l'époque. En réalité, la mémoire de caractère a une sortie de 1 pixel, le pixel en question étant pris dans l'image du caractère sélectionné. L'entrée d'adresse s'obtient alors en concaténant trois informations : le code ASCII pour sélectionner le caractère, le numéro de la ligne et le numéro de la colonne pour sélectionner le bit dans l'image du caractère. Les deux numéros sont fournis par le CRTC, comme on le verra plus bas. ===Le CRTC sur une carte en mode texte=== Le mode texte impose de modifier le CRTC de manière à ce qu'il adresse le tampon de texte correctement. Il contient toujours deux compteurs, pour localiser la ligne et la colonne du pixel à afficher, mais doit transformer cela en adresse de caractère. Le CRTC doit sélectionner le caractère à afficher, puis sélectionner le pixel dans celui-ci, ce qui demande la collaboration de la mémoire de caractères et le tampon de texte. Le CRTC va déduire à quel caractère correspond le pixel choisit, et le récupérer dans le tampon de texte. Là, le code du caractère est envoyé à la mémoire de caractère, et le CRTC fournit de quoi sélectionner le numéro de ligne et le numéro de colonne. Le pixel récupéré dans la mémoire de caractère est alors envoyé à l'écran. Concrètement, les calculs à faire pour déterminer le caractère et pour trouver les numéros de ligne/colonne sont très simples, sans compter que les deux sont liés. Prenons par exemple un écran dont les caractères font tous 12 pixels de large et 8 pixels de haut. Le pixel de coordonnées X (largeur) et Y (hauteur) correspond au caractère de position X/12 et Y/8. Le reste de la première division donne la position de la colonne pour la mémoire de caractère, alors que le reste de la seconde division donne le numéro de ligne. Si les caractères ont une largeur et une hauteur qui sont des puissances de deux, les divisions se simplifient : la position du caractère dans la mémoire se calcule alors à partir des bits de poids forts des compteurs X et Y, alors que les bits de poids faible permettent de donner le numéro de ligne et de colonne pour la mémoire de caractère. Dans le diagramme ci-dessus, les 3 bits de poids faible des registres X et Y de balayage des pixels servent à adresser le pixel parmi les 8x8 du bloc correspondant au caractère à afficher. L'index de ce caractère est lu dans le tampon de texte, adressé par les bits restant des registres X et Y. <noinclude>[[File:Character generator scheme.svg|centre|vignette|upright=2.0|Balayage des pixels par le CRTC pour un mode texte avec des caractères de 8x8 pixels.]]</noinclude> <noinclude>[[File:MC6845.svg|vignette|Motorola 6845]]</noinclude> Un exemple de CRTC est le ''Motorola 6845''. Ce VDP génère l'adresse à lire dans la mémoire vidéo, ainsi que les signaux de synchronisation horizontale et verticale, mais ne fait pas grand-chose d'autre. Lire la mémoire vidéo, extraire les pixels et envoyer le tout à l'écran n'est pas de son ressort. Il gère le mode texte uniquement, mais on peut supporter le mode graphique en trichant. Il supporte le mode entrelacé et le mode non-entrelacé, et est compatible aussi bien avec les moniteurs en PAL qu'en NTSC. Il contient 18 registres dont le contenu permet de configurer le VDP, pour configurer la résolution, la fréquence d'affichage, et d'autres choses encore. D'autres CRTC plus évolués gèrent à la fois le mode texte et le mode graphique, on peut les configurer de manière à choisir lequel utiliser. Il est ainsi possible de prendre un VDP CRTC pour le mettre avec une mémoire assez petite pour gérer uniquement des graphiques en mode texte. Ou au contraire, de prendre un VDP CRTC et de le combiner avec une mémoire importante pour l'utiliser comme ''framebuffer''. ===Le défilement du texte=== Faire défiler du texte est une opération très courante. Concrètement, cela fait descendre d'une ou plusieurs lignes dans le texte, ce qui demande de bouger tout le texte à l'écran. Le défilement ne se fait pas ligne de pixel par ligne de pixel, mais ligne par ligne, voire par paquets de plusieurs lignes. Par exemple, si un caractère de texte fait 8 pixels de haut, alors on saute les lignes par paquets de 8. Il s'agit presque toujours de défilement vertical. Le texte est généralement défilé de haut en bas, verticalement, le défilement horizontal étant plus rare. Même sur les écrans de l'époque, qui avaient des colonnes limitées à 80/100 caractères, le texte était conçu de manière à ce que les lignes de texte ne débordent pas de l'écran. Aussi, les optimisations qui nous intéressent sont surtout les optimisations du défilement vertical. Défiler du texte est une opération très courante qui gagne à être optimisé au niveau de la carte graphique. Les toutes premières cartes graphiques MBA (monochromes) et CGA n'incorporaient pas de défilement vertical optimisé, mais les cartes suivantes, EGA et VGA, le faisaient. Les optimisations en question sont nombreuses, mais nous allons en voir deux. La première optimisation que nous allons voir réutilise les techniques vues dans le chapitre sur le rendu 2D. Pour cela, il utilise un tampon de texte organisé en lignes de texte consécutives. Le tampon de texte mémorise plus de lignes que ce qui est affiché à l'écran. Ce qui est visible à l'écran est une portion du tampon de texte, appelée le ''viewport''. Ainsi, les lignes à afficher au-dessus ou en dessous du ''viewport'' sont déjà en mémoire vidéo. Il y a juste à fournir un registre qui pointe vers la position du ''viewport'' dans le tampon de texte, ce qui permet de faire bouger le ''viewport'' à volonté. Une autre solution, plus économe en RAM, fait usage d'un VDC à co-processeur. Pour rappel, ce sont des VDC qui incorporent un processeur qui exécute un programme d'affichage. Le programme, appelé la ''display list'', afficher une image à l'écran, ou du texte, ou tout ce qu'il faut afficher. Chaque instruction de la ''display list'' dit quoi afficher sur une ligne à l'écran. Ici, elle dit quoi afficher sur une ligne de texte, une ligne de caractère, et non une ligne de pixels comme dans les chapitres précédents. Un exemple de VDC à co-processeur en mode texte est celui des '''ordinateurs Amstrad PCW'''. Il s'agissait d'ordinateurs qui ne géraient que le mode texte, ils ne pouvaient pas afficher d'image pixel par pixel, ils n'avaient pas de ''framebuffer''. Le texte à afficher à l'écran n'était pas stocké dans un tableau unique, ligne par ligne, dans l'ordre de rendu, comme c'est le cas sur les autres cartes en mode texte. A la place, chaque ligne était stocké en mémoire séparément les unes des autres, ou presque. L'affichage était gouverné par une ''display list'' qui disait quelle ligne afficher, et dans quel ordre. La ''display list'' était une liste d'adresses, chacun pointant vers le début d'une ligne en mémoire vidéo. Toutes les lignes faisaient la même taille, ce qui fait que la ''display list'' avait juste à encoder l'adresse de départ de la ligne pour l'afficher correctement. Le processeur du VDC lisait la ''display list'' adresse par adresse et rendait les lignes dans l'ordre précisé par la ''display list''. Le défilement était alors simple à implémenter : il suffisait de modifier le contenu de la ''display list''. Par exemple, pour défiler d'une ligne vers le cas, on décalait son contenu de la ''display list'' d'un cran et on modifiait la ligne la plus en haut. L'avantage est que modifier une ''display list'' est plus rapide que de faire défiler l'ensemble du ''text buffer''. La ''display list'' était stockée dans une mémoire RAM spécialisée de 512 octets, ce qui permettait de stocker 256 adresses de 16 bits chacune. La ''display list'' avait 256 lignes, ce qui collait exactement à la résolution de 720 par 256 pixels de l'Amstrad PCW. LA RAM qui mémorisait la ''display list'' s'appelait la '''''roller RAM'''''. Elle était utilisée par le processeur 280 de la machine pour gérer le rendu de l'affichage. Le VDC utilisé était en effet très simple et se résumait sans doute à un vulgaire CRTC en mode texte. ==Le rendu en mode semi-graphique== [[File:Level 1 teletext test.png|vignette|Rendu d'une image en mode semi-graphique.]] Le rendu en mode texte ne permet en théorie de n'afficher que du texte. Cependant, il est possible d'émuler un rendu graphique à partir du mode texte, en trichant un petit peu. On a alors un mode de rendu appelé '''mode semi-graphique'''. Il y en a deux sous-types, appelés ''rendu graphique en bloc'' et ''pseudo-graphique'', qui seront détaillés ci-dessous. Les mode semi-graphiques sont en soi des techniques logicielles, qui ne demandent pas de support particulier de la part du matériel. Ils servent cependant d'introduction propédeutique au rendu en motifs qui sera vu à la fin du chapitre. La triche n'est possible que sur les cartes en mode texte sur lesquelles les caractères sont configurables, c’est-à-dire qu'on peut préciser à quoi ressemblent les caractères. Sur de telles cartes en mode texte, on peut fournir un dessin rectangulaire de quelques pixels de côté à la carte graphique et lui dire : ceci est le caractère numéro 40. L'idée est de remplacer les caractères par des dessins basiques, des '''motifs''', qui sont assemblés pour fabriquer des "sprites", qui sont eux-même assemblés pour former l'image finale. ==Le rendu à motif (''tiles'')== Le '''rendu en motifs''' est un proche cousin du mode texte. Il n'est cependant pas spécialisé pour du texte, mais permet de compresser des images complètes. Il a été utilisé sur des consoles de jeu, afin d'outrepasser les contraintes en mémoire RAM. les consoles 8 bits avaient peu de mémoire et ne pouvaient pas utiliser de ''framebuffer'', même en utilisant la technique de la palette indicée. Elles ne pouvaient pas non plus utiliser de tampon de ligne, car le processeur n'était pas assez puissant pour. Alors, elles utilisaient le rendu en motifs pour rendre des images avec peu de mémoire vidéo. ===Le stockage des ''sprites'' et de l’arrière-plan : les tiles=== [[File:Tile set.png|vignette|Tile set de Ultima VI.]] Le rendu en motifs force une certaine redondance à l'intérieur de l'arrière-plan et des ''sprites''. L'idée est que les ''sprites'' et l'arrière-plan sont fabriqués à partir de motifs, aussi appelés ''tiles''. Concrètement, ce sont des dessins carrés de 8, 16, 32 pixels de côtés, qui sont assemblés pour fabriquer un ''sprite'' ou l'arrière-plan. L'ensemble des motifs est mémorisée dans un fichier unique, appelé le ''tile set'', le ''tilemap'', ou encore la '''table des motifs'''. La table des motifs est placée dans la cartouche de jeu, souvent dans une mémoire ROM dédiée. Ci-contre, vous voyez la table des motifs du jeu Ultima VI. Les motifs sont numérotés, un numéro identifiant un motif parmi toutes les autres. L'image ne mémorise pas des pixels, mais des numéros de motifs. Le gain est assez appréciable : avec des motifs de 8 pixels de côté, au lieu de stocker X * Y pixels, on stocke X/8 * Y/8 numéros de motifs par image. La mémoire vidéo n'est donc pas un ''framebuffer'', mais un '''tampon de motifs'''. L'image à afficher à l'écran est reconstituée par la carte graphique, lors de l'affichage, en plusieurs étapes. Un avantage est que les motifs peuvent être utilisés en plusieurs endroits, ce qui garantit une certaine redondance. L'arrière-plan, qui est généralement l'image la plus redondante. Par exemple, un ciel est composé de motifs bleus identiques. Idem quand il faut rendre plusieurs petits ennemis identiques à l'écran : on n'utilise qu'un seul motif pour tous les ennemis. Pareil si un motifs est utilisé dans plusieurs ''sprites'', il n'est stockée qu'une seule fois. Il s'agit donc d'une forme de compression d'image qui profite d'une certaine redondance. Ajoutons à cela que les numéros de motifs prennent moins de place que la couleur d'un pixel, et les gains sont encore meilleurs. La consommation mémoire est déportée de l'image vers la mémoire qui stocke les motifs, une mémoire ROM intégrée dans la cartouche de jeu.''' '''. ===L'architecture d'une carte d'affichage en rendu à motifs=== Pour les consoles de 2ème, 3ème et 4ème génération, l'usage de motifs était obligatoire et tous les jeux vidéo utilisaient cette technique. Les cartes graphiques des consoles de jeux de cette époque étaient conçues pour gérer les motifs. Le matériel affichait l'image finale ligne par ligne, pixel par pixel comme le ferait un CRTC. Sauf qu'il détermine : dans quelle motif se trouve le pixel à afficher, où se trouve le pixel dans le motif (quelle ligne, quelle colonne). Une fois cela fait, le VDC accède à la mémoire vidéo pour récupérer le numéro de motif. Une fois le numéro de motif connu, il lit la table des potifs en mémoire ROM, sur la cartouche. Puis, il sélectionne le pixel à afficher dans ce motif, et l'envoie à la palette indicée, puis à l'écran. [[File:VDC à rendu à motif, sans gestion des sprites.png|centre|vignette|upright=2|VDC à rendu à motif, sans gestion des sprites]] Les motifs sont des équivalents des caractères dans le mode texte. Un motif peut être vu comme une sorte de super-caractère. Une carte d'affichage en mode texte et une carte en rendu à motifs sont d'ailleurs très similaires. Le tampon de motifs est l'équivalent pour le rendu à motifs du tampon de texte en mode texte. La table des motifs est l'équivalent de la table des caractères, les deux convertissent un caractère/''tile'' en pixels. La méthode d'adressage est fortement similaire, l'utilisation l'est aussi. La seule différence est leur contenu, la table des caractères stockant des caractères, la table des motifs stockant des motifs graphiques. Une optimisation permettait de lire certains motifs dans les deux sens à l'horizontale, de faire une sorte d'opération miroir. Ce faisant, on pouvait créer un objet symétrique en mémorisant seulement un motif. Par exemple, la moitié droite est générée par une opération miroir sur la partie gauche. Mais cette optimisation était assez rare, car elle demandait d'ajouter des circuits dans un environnement où le moindre transistor était cher. De plus, les objets symétriques sont généralement assez rares. Les ''sprites'' sont presque toujours gérés avec le rendu à motifs, pour des raisons de performance. Les ''sprites'' les plus simples sont un seul motif, mais les autres ''sprites'' sont formés en assemblant plusieurs motifs. Typiquement, un ''sprite'' prend de 2 à 4 motifs. Par exemple, un ''sprite'' de 16 pixels de haut et 8 pixels de large est composé de deux motifs de 8 pixels de côté. Pour cela, les registres pour les ''sprites'' mémorisent des numéros de motif, pas des pixels. [[File:Carte 2D avec un rendu en tile.png|centre|vignette|upright=2|Carte 2D avec un rendu en tile]] ===Le défilement avec des motifs=== Le défilement se marie assez mal avec un rendu à base de motifs. La solution la plus simple fait du défilement motif par motif, par sauts de 8 pixels. L'implémentation est la même qu'avec le défilement, à savoir qu'on utilise un ''viewport'' matériel. La différence est que la mémoire vidéo contient des motifs et non des pixels, idem pour le ''viewport''. Le résultat à l'écran n'est pas fluide du tout, il donne un défilement saccadé et désagréable. Pour corriger cela, il a existé des techniques pour implémenter du défilement du défilement pixel par pixel avec un rendu à motifs. Un défilement de ce type est appelé un '''défilement fluide''', ou encore du '''défilement pixel par pixel'''. La première implémentation du défilement fluide avec des motifs était purement logicielle, le VDC faisait lui du défilement motif par motif. Sur PC, la première implémentation a été trouvée par John Carmack, le programmeur derrière les moteurs des jeux IDSoftware comme DOOM, Quake, Wolfenstein 3D, etc. Il a inventé la technique de l'''Adaptive tile refresh'' qui permet justement d'avoir un défilement fluide sur des cartes sans gestion hardware du défilement. D'autres solutions logicielles similaires existaient sur console. Elles utilisaient 8 copies de chaque motif, chacun étant décalé d'un pixel. Le motif adéquat était choisi suivant un décalage dépendant du défilement. Mais il est possible de faire du défilement pixel par pixel avec un rendu à motifs, en ajoutant du hardware spécifique au VDC. Elles étaient implémentées sur les consoles de 4ème génération et antérieures, mais pas sur les anciens PC. Une des toute première console à gérer le défilement pixel par pixel était l'Intellivision, une des toutes premières consoles, précisément une console de deuxième génération. Elle avait des motifs de 8 pixels de côté, et permettait de défiler au pixel près. Pour cela, elle disposait d'un '''registre de décalage''' contenant une valeur allant de 0 à 7, qui indiquait de combien de pixels il fallait décaler l'image sur l'écran. Pour décaler d'une valeur plus grande, il fallait recourir au logiciel. Le logiciel devait alors faire défiler l'image en la déplaçant en mémoire vidéo. Il s'agissait donc d'une implémentation partielle du défilement avec des motifs. Le logiciel faisait du défilement motif par motif, et pouvait finir le travail en ajustant le tout pour obtenir un défilement pixel par pixel. Les consoles suivantes géraient le défilement pixel par pixel d'une manière plus optimisée, en gérant à la fois le registre de décalage et le défilement motif par motif. Le défilement pixel par pixel était réalisé en faisant un défilement motif par motif, puis en finissant le travail avec un registre de décalage. Le défilement motif par motif était géré comme expliqué ci-dessus, avec un ''viewport''. ===Les glitchs graphiques sur les bords de l'écran=== Malgré leur support matériel, il arrive que le défilement au pixel près cause des glitchs graphiques sur les bords de l'écran. La raison est qu'avec un défilement par pixel, les motifs aux bords de l'écran sont partiellement affichés. Par exemple, un motif sur le bord gauche ait ses pixels gauche en-dehors de l'écran, ses pixels droits dans l'écran. Idem mais sur le bord droit : un motif a ses pixels de gauche dans l'écran, ceux de droite en-dehors. Le problème est que de tels motifs peuvent ne pas être affichés si le VDC ne le permet pas. En général, les motifs partiellement en-dehors de l'écran ne sont pas affichés, ce qui cause des glitchs graphiques. Les VDC de l'époque ne pouvaient pas afficher un motif si ses coordonnées sont en-dehors de l'écran. En général, la coordonnée d'un motif est définie par rapport à une origine placée sur le pixel le plus en haut et le plus à gauche du motif. Dans ce cas, les motifs à gauche de l'écran ont une origine qui est en-dehors de l'écran, même si quelques pixels de droite sont affichés. Le motif n'est alors pas affiché. Ces pixels à droite disparaissent, ce qui fait qu'entre 0 et 7 pixels disparaissent pour un motif de 8 pixel de largeur, entre 0 et 15 pixels s'il a une largeur de 16 pixels, etc. Idem avec le défilement vertical, mais pour les motifs tout en haut de l'écran. : Si l'origine du motif est définie sur le bord droit, c'est les motifs à droite de l'écran qui disparaissent. Il y a la même chose avec les ''sprites''. Si leur origine est en-dehors de l'écran, mais que le reste du ''sprite'' est affiché, ils ne sont pas affichés. Une solution pour éviter les problèmes est de les faire rentrer d'un seul côté de l'écran, typiquement à droite si leur origine est définie à gauche. Si le défilement va dans le bon sens, aucun glitch graphique ne se manifeste. Pour cacher ces glitchs graphiques, il est possible d'ajouter des bandes noires sur les deux côtés de l'écran. Les bandes noires sont verticales et/ou horizontales, elle ont une largeur égale à un motif. Le VDC peut gérer nativement ces bandes noires, en matériel. Pour cela, il réduit simplement la résolution pour couper les bords de l'écran. {{NavChapitre | book=Les cartes graphiques | prev=Les cartes accélératrices 2D | prevText=Les cartes accélératrices 2D | next=Les accélérateurs de scanline | nextText=Les accélérateurs de scanline }} {{autocat}} 88ps1foq5v5tpc9johxicghn3as3cdt Les cartes graphiques/La microarchitecture des processeurs de shaders 0 81538 765155 764883 2026-04-26T20:16:50Z Mewtow 31375 /* Le banc de registre est multiport de type externe */ 765155 wikitext text/x-wiki La conception interne (aussi appelée microarchitecture) des processeurs de ''shaders'' possède quelques idiosyncrasies. Mais avant d'expliquer lesquelles, nous devons prévenir d'une chose importante : dans ce chapitre, nous ne parlerons que des GPU de l'époque DirectX 10 et après, pas des GPU de l'époque DirectX 9. La raison est que leur jeu d'instruction a franchement évolué, avec le passage d'architectures VLIW à des architectures SIMD. Et cela a eu des conséquences assez profondes sur le jeu d'instruction et leur microarchitecture. Nous n'allons parler des GPU de type SIMD dans ce chapitre. Un chapitre dédié sera consacré aux GPU de type VLIW. Pour rappel, un processeur de shader supporte plusieurs types d'instructions. Au minimum, il supporte des ''instructions SIMD''. Mais il peut aussi gérer des ''instructions scalaires'', à savoir qu'elles travaillent sur des entiers/flottants isolés, en dehors d'un vecteur SIMD. Typiquement, il y a plusieurs types d'instructions scalaires : les calculs entiers, les calculs flottants simples, les calculs flottants complexes dit transcendantaux (calculs trigonométriques, des exponentielles, des logarithmes, des racines carrées ou racines carrées inverse). En clair, un processeur de shader doit savoir faire des calculs. Et il a des circuits dédiés pour ça, appelés des unités de calcul. Les unités de calcul sont souvent classées en deux types : les ALU et les FPU. Les premières font des calculs sur des opérandes entiers, alors que les secondes font des calculs flottants. Au passage, le terme ALU signifie ''Arithmetic and Logic Unit'', alors que FPU signifie ''Floating Point Unit'', les deux termes étant assez parlants. Mais un GPU utilise non seulement des ALU et des FPU, mais aussi des regroupements de plusieurs ALU/FPU. Voyons cela en détail. ==Les unités de calcul d'un processeur de shader SIMD== Un processeur de shader contient un grand nombre d'unités de calcul très différentes. Le cœur est une unité de calcul SIMD, qui se charge des instructions SIMD. A cela, il faut souvent rajouter une ALU ou une FPU, parfois des unités de calcul flottantes spécialisées pour les opérations complexes, comme des opérations trigonométriques ou transcendantales. ===Les unités de calcul SIMD=== [[File:SIMD2.svg|vignette|Une unité de calcul SIMD.]] Un processeur de shader incorpore une unité de calcul SIMD, qui effectue plusieurs calculs en parallèle. Elle regroupe plusieurs ALU flottantes regroupées ensemble et avec quelques circuits pour gérer les débordements et d'autres situations. En théorie, une unité de calcul SIMD regroupe autant d'unité de calcul qu'il y a d'entiers/flottants dans un vecteur. Par exemple, pour additionner deux vecteurs contenant chacun 16 flottants, il faut utilise 16 additionneurs flottants. Ce qui fait qu'une opération sur un vecteur est traité en une seule fois, en un cycle d'horloge. Une contrainte importante est que toutes les sous-ALU effectuent la même opération : ce ne sont pas des ALU séparées qu'on peut commander indépendamment, mais une seule ALU regroupant des circuits de calcul distinct. Le cout en circuit est d'autant plus grand que les vecteurs sont longs et le cout est approximativement proportionnel à la taille des vecteurs. Entre des vecteurs de 128 et 256 bits, l'unité de calcul utilisera globalement deux fois plus de circuits avec 256 bits qu'avec 128. Même chose pour les registres, mais c'est là un cout commun à toutes les architectures. Il y a quelques unités de calcul SIMD où le calcul se fait en deux fois, car on n'a que la moitié des unités de calcul. Par exemple, pour un vecteur de 32 flottants, on peut utiliser 16 unités de calcul, mais le temps de calcul se fait en deux cycles d'horloge. Les opérations sur les vecteurs sont donc faites en deux fois : une première passe pour les 16 premiers éléments, une seconde passe pour les 16 restants. L'implémentation demande cependant qu'une instruction de calcul soit décodée en deux micro-opérations. Par exemple, une instruction SIMD sur des vecteurs de 32 éléments est exécutée par deux micro-instructions travaillant sur des vecteurs de 16 éléments. On économise ainsi pas mal de circuits, mais cela se fait au détriment de la performance globale. L'avantage est que cela se marie bien avec l'abandon des opérations pour lesquelles masques dont tous les bits sont à 0. Par exemple, prenons une instruction travaillant sur 16 flottants, exécutée en deux fois sur 8 flottants. Si le masque dit que les 8 premières opérations ne sont pas à exécuter, alors l'ALU ne fera que le calcul des 8 derniers flottants. Pour cela, le décodeur doit lire le registre de masque lors du décodage pour éliminer une micro-instruction si besoin, voire les deux si le masque est coopératif. ===Plusieurs unités SIMD, liées au format des données=== Il faut préciser qu'il y a une séparation entre unités SIMD flottantes simple et double précision. Pour le dire plus clairement, il y a des unités SIMD pour les flottants 32 bits, d'autres pour les flottants 64 bits, et même d'autres pour les flottants 16 bits. Les flottants 64 bits sont utilisés dans les applications GPGPU, les flottants 16 et 32 bits le sont dans le rendu 3D, et les flottants 16 bits pour tout ce qui lié à l'IA. Malheureusement, on doit utiliser des ALU flottantes séparées pour chaque taille, le format des flottants n'aidant pas. Depuis plus d'une décennie, les cartes graphiques ont des unités SIMD à la fois pour les calculs entiers et flottants. Elles sont censées être séparées. Pour NVIDIA, avant l'architecture Turing, les unités SIMD entières et flottantes sont décrites comme séparées dans leurs ''white papers'', avec des unités INT32 et FLOAT32 séparées, combinées à d'autres unités de calcul. L'architecture Volta a notamment des unités INT32, FLOAT32 et FLOAT64 séparées. A partir de l'architecture Ampere, il semblerait que les unités SIMD soient devenues capables de faire à la fois des calculs flottants et entiers, pour la moitié d'entre elles. Mais il se pourrait simplement qu'elles soient physiquement séparées, mais reliées aux reste du processeur de manière à ne pas être utilisables en même temps. Il est cependant possible que sur d'anciennes architectures, les unités entières et flottantes partagent des circuits, notamment pour ce qui est de la multiplication. En effet, une unité de calcul flottante contient des circuits pour faire des calculs entiers pour additionner/multiplier les mantisses et les exposants. Il est possible d'utiliser ce circuit Un exemple est le cas de l'architecture GT200 d'NVIDIA, sur laquelle les "pseudo-ALU" entières SIMD étaient limitées à des multiplications d'opérandes 24 bits, ce qui correspond à la taille d'une mantisse d'un flottant 32 bits. Le design exact des ALU n'est pas connu. ===Les unités de calcul scalaires=== Les GPU modernes incorporent une '''unité de calcul entière scalaire''', séparée de l'unité de calcul SIMD. Elle gère des calculs scalaires, à savoir qu'elle ne travaille pas sur des vecteurs. Elle gère divers calculs, comme des additions, soustractions, comparaisons, opérations bit à bit, etc. Elle exécute les instructions de calcul entière sur des nombres entiers isolés, de plus en plus fréquentes dans les shaders. Elle est parfois accompagnée d'une unité de calcul pour les branchements. Par branchements, on veut parler des vrais branchements similaires à ceux des CPU, qui effectuent des tests sur des entiers et effectuent des branchements conditionnels. Ils n'ont rien à voir avec les instructions à prédicat qui elles sont spécifiques à l'unité de calcul vectorielles. Ce sont des instructions séparées, totalement distinctes Les processeurs de shaders incorporent aussi une '''unité de calcul flottante scalaire''', utilisée pour faire des calculs sur des flottants isolés. L'unité de calcul gère généralement des calculs simples, comme les additions, soustractions, multiplications et divisions. Il s'agit typiquement d'une unité de calcul spécialisée dans l’opération ''Multiply-And-Add'' (une multiplication suivie d'une addition, opération très courante en 3D, notamment dans le calcul de produits scalaires), qui ne gère pas la division. L'unité de calcul flottante est souvent accompagnée d'une unité de calcul spécialisée qui gère les calculs transcendantaux, avec une gestion des calculs trigonométriques, de produits scalaires ou d'autres opérations. Elle porte le nom d''''unité de calcul spéciale''' (''Special Function Unit''), ou encore d'unité de calcul transcendantale, et elle a d'autres appellations. Elle est composée de circuits de calculs accompagnés par une table contenant des constantes nécessaires pour faire les calculs. Rappelons que les registres SIMD et les registres scalaires sont séparés et ne sont pas adressés par les mêmes instructions. Les registres scalaires sont placés dans un banc de registre physiquement séparé du banc de registres SIMD. Le banc de registre scalaire est relié à sa propre ALU scalaire, il y a vraiment une séparation physique entre registres scalaires et SIMD. Il existe cependant un système d'interconnexion qui permet d'envoyer un scalaire aux unités SIMD, ce qui est utile pour les opérations de produits scalaire ou autres. ==L'intérieur d'un processeur de shader== : Cette section sera surtout des rappels, pour ceux qui ont déjà lu un cours d'architecture des ordinateurs, et connait la notion de pipeline. En plus des unités de calcul, un processeur contient d'autres circuits, et un processeur de shader ne fait pas exception. Un processeur est composé de quatre circuits principaux : * les unités de calcul, qui font des calculs et d'autres opérations ; * les registres pour mémoriser les opérandes des calculs et leurs résultats ; * une unité mémoire pour échanger des données entre VRAM et registres ; * une unité de contrôle qui exécute les instructions. Les unités de calcul, les registres et l'unité mémoire sont souvent regroupés sous le terme de '''chemin de données'''. Le terme dit clairement que c'est la partie du processeur qui gère les données, les manipule, fait des calculs, etc. A côté du chemin de données, il y a une unité de contrôle, qui commande ce chemin de données pour qu'il fasse les instructions demandées. L'unité de contrôle lit les instructions depuis la mémoire vidéo, et configure le chemin de données, pour qu'il exécute l'instruction demandée. L'unité de contrôle sera détaillée dans la section suivante. ===Le chemin de données d'un processeur de shader=== Un processeur de shader contient au minimum une unité SIMD, et une unité scalaire. Il est possible d'utiliser les deux en même temps, grâce aux instructions à ''co-issue''. Elles sont surtout utiles pour exécuter des instructions scalaires en parallèle d'instruction SIMD, mais guère plus. Notez que j'ai dit : "au minimum une unité SIMD et une unité scalaire", car les processeurs de shaders modernes dupliquent les unités de calcul, pour des raisons qu'on expliquera dans la suite du chapitre. Il n'est pas rare qu'un processeur de shader dispose d'une dizaine d'unités SIMD et de 2 ou 3 unités scalaires. Nous avons vu les registres dans le chapitre précédent, aussi je ne vais pas revenir dessus. Je vais juste préciser que les registres sont regroupés dans des '''bancs de registres'''. Ce sont de petites mémoires dont chaque adresse contient un registre. En général, il y a un banc de registre pour les scalaires et un autre pour les vecteurs SIMD. Les deux sont séparés car ils ne sont pas utilisés par les mêmes instructions. Et c'est plus pratique à implémenter. L''''unité d'accès mémoire''' s'occupe des lectures et écriture en général, et elle prend en charge les accès aux textures, le filtrage de textures et tout un tas de fonctionnalités liées aux textures. La seule différence entre un accès aux textures et une lecture/écriture en mémoire est que les circuits de filtrage de texture sont contournés dans une lecture/écriture normale. Dans ce qui suit, nous allons l'appeler l'unité de texture par souci de simplification. ===L'unité de contrôle d'un processeur de shader=== L'unité de contrôle d'un GPU a quelques petites différences avec celles d'un CPU moderne. Les unités de contrôle des GPU n'utilisent pas les optimisations des CPU modernes, tant utiles pour du calcul séquentiel : pas d’exécution dans le désordre, de renommage de registres, et autres techniques avancées. A la place, elles utilisent des techniques alternatives qu'on décrira pdans la suite du chapitre, qui sont peu gourmandes en transistors. En conséquence, les unités de contrôle sont très simples, prennent peu de place, utilisent peu de transistors. La majeure partie du processeur est dédié aux unités de calcul et aux registres. [[File:Cpu-gpu.svg|centre|vignette|upright=2.0|Comparaison entre l'architecture d'un processeur généraliste et d'un processeur de shaders.]] Un processeur de shader a une unité de contrôle assez classique, composée de plusieurs circuits. * une unité de ''Fetch'' qui calcule l'adresse de la prochaine instruction ; * un cache d'instruction dans lequel on récupère la prochaine instruction, en présentant son adresse ; * une unité de décodage d'instruction, qui traduit l'instruction en signaux de commande à destination de l'unité de calcul et des registres ; * une unité d’''issue'', aussi appelée le ''scoreboard'', qu'on détaillera dans ce qui suit. Le tout est illustré ci-dessous, avec le chemin de données. Vous remarquerez que dans le chemin de données, il y a aussi une unité pour enregistres les résultats dans les registres, qui effectue pas mal de traitements importants qu'on ne peut pas détailler ici. Vous remarquerez aussi que l'unité qui calcule l'adresse de la prochaine instruction est un peu complexe. Mais laissons cela de côté pour le moment. [[File:Vortex microarchitecture.png|centre|vignette|upright=2.5|Exemple de microarchitecture d'un processeur de shader.]] ===Le pipeline d'un processeur de shader=== Un point important est que les processeurs de shaders utilisent la technique du '''pipeline'''. Les unités vues précédemment fonctionnent indépendamment des autres. Elles n'attendent pas que l'instruction soit terminée pour commencer à traiter la suivante. Concrètement, la première unité traite l'instruction courante, pendant que la suivante traite l'instruction précédente, et ainsi de suite : * la première unité calcule l'adresse de l'instruction numéro N; * le cache d'instruction lit l'instruction numéro N-1 ; * l'unité de décodage décode l'instruction numéro N-2 ; * le ''scoreboard'' analyse l'instruction numéro N-3 ; * les unités de calcul exécutent l'instruction numéro N-4 ; * l'unité d'enregistrement écrit le résultat de l'instruction numéro N-5 dans les registres. Faire ainsi maximise les performances, car cela permet d'exécuter plusieurs instructions en même temps, à des étapes différentes. Le débit d'instruction est maximisé, les shaders s'exécutent plus vite. Le nombre d'étapes exact dépend du processeur. Il arrive que des processeurs fusionnent des étapes en un seul circuit. Par exemple, l'unité de décodage et le ''scoreboard'' peuvent être regroupés dans une seule étape, de même que l'accès aux registres et le calcul dans l'ALU. D'autres processeurs scindent certaines étapes en plusieurs sous-circuits séparés. Par exemple, il peuvent scinder leurs ALU en plusieurs sous-ALU, chacune exécutant un morceau de calcul. Et cela permet d'expliquer pourquoi un processeur de ''shaders'' SIMD contient beaucoup d'unités de calcul, identiques, ou non. Les instructions prennent plusieurs cycles d'horloge à s’exécuter, ce qui fait qu'une instruction occupe une unité de calcul pendant 2 à 20 cycles, rarement plus. Cependant, le processeur peut démarrer une nouvelle instruction par cycle d'horloge. Et cela permet malgré tout de démarrer une nouvelle instruction dans une unité de calcul libre. Pendant qu'une instruction en est à son second ou troisième cycle dans une ALU, il est possible de démarrer une nouvelle instruction dans une ALU inoccupée, sans voir recours à la ''co-issue''. s'il y en a une. Par exemple, reprenons l'exemple de l'unité de ''vertex shader'' de la Geforce 6800, mentionné au-dessus. Elle dispose d'une unité de calcul SIMD MAD, et d'une unité de texture, ainsi que d'une unité de calcul scalaire transcendantale. Il en en théorie possible de faire en même temps : un calcul dans l'ALU SIMD, une lecture de texture dans l'unité mémoire, un calcul trigonométrique dans l'unité transcendantale. Il suffit de lancer la lecture de texture à un cycle, l’instruction MAD au cycle suivant, et l'instruction spéciale deux cycles après. ===Le ''scoreboard'' d'un processeur de shader=== Exécuter plusieurs instructions en même temps pose un problème quand deux instructions consécutives sont dépendantes l'une de l'autre. Un cas classique est quand une instruction a besoin du résultat de la précédente. Dans ce cas, l'instruction accède aux registres alors que la première n'a pas encore écrit dedans, ce qui pose un problème. Il s'agit d'une dépendance dite RAW (''Read After Write'') typique, que la carte graphique doit gérer automatiquement. La seconde instruction ne doit pas démarrer tant que la précédente n'a pas enregistré son résultat dans les registres. Le ''scoreboard'' gère ce genre de problèmes. Il détecte les dépendances entre instructions et les gère sans intervention extérieure. Pour cela, il vérifie si les opérandes de l'instruction sont en cours de calcul. Pour cela, il regarde quels registres l'instruction lit/écrit, et vérifie s'ils sont en cours d'utilisation. Pour savoir quels registres sont en cours d'utilisation, rien de plus simple : quand il démarre une instruction, le ''scoreboard'' marque ses registres comme en cours d'utilisation. Le ''scoreboard'' se contente de bloquer l'instruction tant qu'elle ne peut pas s'exécuter. Et les instructions suivantes sont aussi bloquées dans l'étage où elles sont. Rien ne progresse dans le pipeline tant que l'instruction fautive est bloquée. Cependant, les processeurs de shaders disposent de plusieurs optimisations concernant ce genre de situations. Voyons cela en détail. Et au-delà de ça, le ''scoreboard'' bloque l'exécution d'une instruction si les conditions ne sont pas remplies. Notamment, il vérifie qu'il y a une unité de calcul libre pour exécuter l'instruction. Si ce n'est pas le cas, l'instruction est bloquée. Au-delà de ça, il existe d'autres dépendances liées au fait que deux instructions utilisent les mêmes registres. Mais avec 4096 registres par ''shader'', elles sont plus rares, ce qui fait qu'on les laisse volontairement de côté. ===Exemple et résumé final=== Pour finir, nous allons voir un exemple final, celui des GPU Radeon X1000 series, de microarchitecture R500, Terascale. Leur microarchitecture est résumée dans le schéma ci-dessous. La portion gauche du schéma montre plusieurs choses. Le GPU contient un processeur de commande, appelé l'''ultra threaded dispatcher'' sur ces GPU. Il alimente plusieurs processeurs de shaders, ici appelés des ''compute unit'' (CU). De nombreux circuits sont partagés entre plusieurs processeurs. Par exemple, ils partagent un cache L2, dans lequel ils viennent récupérer les données nécessaires. Le GPU contient aussi des contrôleurs mémoire, qui lisent ou écrivent des données en mémoire vidéo. Les contrôleurs mémoire servent surtout d'interface entre la mémoire vidéo et le cache L2, mais ils peuvent aussi envoyer des données lue aux processeurs de shaders. Le focus de droite montre ce qu'il y a dans un processeur de shader. Déjà, l'unité de contrôle est en haut et est nommée ''Fetch, Decode, Schedule'' : ''Schedule'' est un synonyme de ''Issue''. L'unité mémoire est appelée la ''Load Store Unit'' (LSU), elle communique avec la mémoire vidéo et les registres. A côté, on trouve une unité de calcul SIMD et une unité de calculs scalaire. Pour ce qui est des mémoires, elle montre qu'il y a une petite mémoire locale généraliste, complétée avec deux bancs de registres : un pour les données scalaires, un pour les vecteurs SIMD. * Les registres pour les scalaires sont appelés les ''Scalar General Purpose Registers'' (GPR). * Les registres pour les vecteurs SIMD sont appelés les ''Vector General Purpose Registers'' (VGPR). * La mémoire locale généraliste, appelée la mémoire partagée (LDS). Niveau interconnexions, les flèches montrent plusieurs choses. Premièrement, l'unité mémoire est reliée aux bancs de registres, ainsi qu'à la mémoire locale. Les accès à la mémoire locale passent par l'unité mémoire, qui sert d'intermédiaire obligatoire. L'unité SIMD est connectée aux registres SIMD, l'unité scalaire est reliée au banc de registres scalaire. Rien d'étonnant. L'unité SIMD peut aussi lire des scalaires pour certaines instructions SIMD verticales, ce qui fait qu'elle est aussi connecté au banc de registres scalaires. [[File:MIAOW GPU diagram.png|centre|vignette|upright=2|Microarchitecture d'un GPU, avec un focus sur un processeur de shader.]] ==Le ''multithreading'' matériel des processeurs de shaders== L'unité d'''issue'' détecte les dépendances de données, et bloque les instructions si elles ne doivent pas s'exécuter. L'inconvénient est que, quand une instruction est bloquée, les instructions suivantes sont aussi bloquées dans l'étage où elles sont. Rien ne progresse dans le pipeline tant que l'instruction fautive est bloquée. Heureusement, les GPU et les CPU disposent de techniques pour surmonter ce blocage, afin de continuer à exécuter des instructions. Les CPU disposent de techniques d'exécution dans le désordre, de renommage de registre, et bien d'autres. Mais leur implémentation demande un budget en transistor conséquent, que les GPU ne peuvent pas se permettre. A la place, ils utilisent une technique appelée le '''''multithreading'' matériel''', qui vient du monde des CPU. Vous connaissez sans doute l'''hyperthreading'' d'Intel ? C'est une version basique du ''multithreading'' matériel. L'idée est d'exécuter plusieurs programmes en même temps sur le même processeur, le processeur commutant de l'un à l'autre suivant les besoins. Par exemple, si un programme est bloqué par un accès mémoire, d'autres programmes exécutent des calculs dans l'unité de calcul en parallèle de l'accès mémoire. Pour un GPU, les programmes en question sont des instances de shader qui travaillent sur des données différentes. Ces instances de shader portent les noms de ''warp'' dans la terminologie NVIDIA, mais on peut aussi parler de ''threads'' pour utiliser la même terminologie que pour les CPUs. Un processeur de ''shader'' commute donc régulièrement d'un ''warp'' à l'autre, suivant les besoins. Dans ce qui va suivre, nous allons voir dans quelles situations un processeur de ''shader'' change de ''thread''/''warp'' en cours d'exécution. Suivant le GPU, les situations ne sont pas les mêmes. Il existe trois techniques de ''multithreading'' matériel : le ''Fine Grained Multithreading'', le ''Coarse Grained Multithreading'' et le ''Simultaneous MultiThreading''. Dans ce qui suit, nous utiliserons l'abréviation FGMT pour parler du ''Fine Grained Multithreading'', de CGMT pour parler du ''Coarse Grained Multithreading'' et de SMT pour le ''Simultaneous MultiThreading''. Les GPU ont d'abord implémenté le CGMT dans la période DirectX 9, puis sont passé au FGMT, avant de passer au SMT sur les générations récentes. Aussi, nous allons les voir dans l'ordre. ===Le ''Coarse Grained Multithreading'' de l'époque DirectX 9=== Les processeurs de shader sont connectés à une mémoire vidéo très lente, avec un temps d'accès élevé, qui se rattrape avec un débit binaire important. La conséquence est qu'un accès à une texture, c'est long : si celle-ci est lue depuis la mémoire vidéo, le temps d'attente est d'une bonne centaine de cycles d'horloges. Pour limiter la casse, les unités de texture incorporent un cache de texture, mais cela ne suffit pas toujours à alimenter les processeurs de shaders en données. Et ces derniers ne peuvent pas recourir à des techniques avancées communes sur les CPU, comme l’exécution dans le désordre : le cout en circuit serait trop important. [[File:Coarse Grained Multithreading.png|thumb|Coarse Grained Multithreading.]] Fort heureusement, les processeurs de shaders utilisent le ''multithreading'' matériel pour masquer la latence des accès mémoire. L'idée est que si un ''thread'' démarre un accès mémoire, il est mis en pause pendant l'accès mémoire, et laisse sa place à un autre ''thread''. Ainsi, pendant qu'un ''thread'' est bloqué par un accès mémoire, un autre ''thread'' utilise les unités de calcul en parallèle. Cela permet de masquer la latence des accès mémoire. : Notons qu'avec cette technique, les lectures mettent en pause le ''thread'' qui les exécute. On parle alors de '''lectures bloquantes'''. Nous verrons que les processeurs de shader plus récents exécutent des lectures non-bloquantes, mais ce sera pour la suite. La technique ne donne de bons résultats que si les accès mémoire sont peu fréquents, ou que le nombre de ''threads'' est élevé. Plus les accès mémoire sont fréquents, plus il faut un nombre de ''threads'' important pour masquer la latence. A l'époque, il était rare que les ''vertex shader'' accèdent à des textures, alors que les pixels shaders ne faisaient que ça. Ls processeurs de shaders de la Geforce 6 géraient au maximum 4 ''threads'' simultanés, alors que les processeurs de pixel shaders en géraient facilement plus d'une centaine. Les cartes concurrentes d'ATI supportaient 128 ''threads'' maximum, par processeur de pixel shader. L'implémentation de cette technique est assez simple, au premier abord. Déjà, il y a un ''Program Counter'' par ''thread''. À chaque cycle, un multiplexeur choisit le ''Program Counter'' - le thread - qui a la chance de charger ses instructions. Le choix du ''program counter'' sélectionné est le fait de l''''unité d'ordonnancement'''. L'unité d'ordonnancement sait quels ''threads'' sont en cours d'exécution et lesquels sont en pause. Pour cela, elle intègre une petite mémoire qui mémorise l'état de chaque ''thread''. [[File:Architecture d'un processeur multithreadé.png|centre|vignette|upright=2|Architecture d'un processeur multithreadé]] La technique impose cependant que les registres soient dupliqués, pour que chaque ''thread'' ait ses propres registres rien qu'à lui. Sans cela, impossible de passer rapidement d'un ''thread'' à l'autre à chaque cycle. Un processeur de ''shader'' peut exécuter entre 16 et 32 ''threads''/''warps'', ce qui multiplie le nombre de registres par 16/32. Et il y a la même chose avec d'autres structures matérielles, comme les files de lecture/écriture de l'unité d'accès mémoire. [[File:Aperçu de l'architecture d'un processeur multithreadé.png|centre|vignette|upright=2|Aperçu de l'architecture d'un processeur multithreadé]] : La technique générale porte le nom de ''Coarse Grained Multithreading''. C'est une forme de ''Multithreading'' où on change de programme quand un évènement bien précis a lieu. Les GPU en utilisaient une version où les évènements en question sont des accès mémoire. En théorie, la technique s'adapte pour d'autres opérations que les accès mémoire, tant que celles-ci prennent beaucoup de temps. Mais je ne saurais dire si les GPU l'appliquaient pour autre chose que les accès mémoire. ===Interlude propédeutique : le ''Fine Grained Multithreading''=== [[File:Fine Grained Multithreading.png|thumb|Fine Grained Multithreading]] Peu avant les années 2010, les processeurs de shaders ont subit quelques changements, afin d'augmenter leur performance. Notamment, le nombre d'étapes du pipeline a augmenté, histoire d’exécuter plus d'instructions simultanément. Et cela a commencé à poser quelques problèmes : les situations où deux instructions utilisent les mêmes registres a augmenté, les dépendances de données sont devenues un problème. Et pour résoudre ces problèmes, les GPU ont basculé du CGMT vers une forme de ''multithreading'' qu'on ne trouve que sur les GPU, ou presque. Et pour la comprendre, nous allons devoir faire un léger détour par une forme de ''multithreading'' très similaire, utilisée sur les CPU. Il s'agit du ''Fine Grained Multithreading'' (FGMT). Avec le FGMT, le processeur de shader change de ''thread'' à chaque cycle d'horloge. Le processeur fait donc une rotation, à chaque cycle, parmi les ''threads'' actifs. Les ''threads bloqués'' par un accès mémoire ne sont pas pris en compte. Par exemple, imaginons que le processeur gère 32 ''threads'' simultanés, mais que 8 d'entre eux soient en pause lors d'un accès mémoire. Dans ce cas, il changera de ''thread'' tous les 24 cycles, car il ne prend en compte que les ''threads'' non-bloqués. Faire ainsi a de nombreux avantages, notamment pour ce qui est des dépendances entre instructions. En changeant de ''thread'' à chaque cycle, on "espace" les instructions d'un même ''thread''. Par exemple, si on a 8 ''threads'' qui s'exécutent en même temps, alors il y a 8 cycles d'horloges entre deux instructions d'un même ''thread''. La première instruction a alors eu tout le temps pour enregistrer son résultat dans les registres, avant même que la seconde instruction lise ses opérandes. Le processeur peut alors utiliser un ''scoreboard'' très limité, très simple, pour détecter les rares dépendances de données qui restent, voire peut se passer complétement de ''scoreboard'' ! ===Le ''Multithreading'' commandé par le ''scoreboard'' des années 2005-2010=== [[File:Full multithreading.png|thumb|Full multithreading]] Sur les GPU récents, le processeur de shader ne change pas de ''thread'' à chaque cycle. Il préfère exécuter plusieurs instructions consécutives d'un même ''thread'', dans des cycles d'horloge consécutifs. Il ne change de ''thread'' que quand une dépendance de donnée bloque une instruction. C'est donc le ''scoreboard'' qui commande le changement de ''thread'', là où le changement de ''thread'' était réalisé sans lui avec le CGMT ou le FGMT. L'implémentation matérielle sépare le processeur de ''shader'' en deux sections séparées par une mémoire tampon qui met en attente les instructions. Les instructions sont chargées et décodées, puis sont placées dans une ou plusieurs '''files d'instruction'''. Le cas le plus simple à comprendre utilise une file d'instruction par ''thread''. A chaque cycle, l'unité d'émission vérifie plusieurs instructions, une par file d'instruction. Il choisit alors une instruction prête, puis l'envoie aux unités de calcul. L'instruction peut venir de n'importe quelle file d'instruction. Mais pour que cela fonctionne, il faut que les files d'instructions soient remplies, ce qui implique que le processeur doit avoir chargée des instructions en avance. Plus haut, nous avions dit que l'unité de chargement mémorise plusieurs ''program counter'', un par ''thread'', et choisit à chaque cycle l'un d'entre eux pour charger une instruction. Le choix en question est synchronisé avec l'émission des instructions. Si un ''thread'' émet une instruction, ce même ''thread'' charge une instruction au même moment. Sauf si la file d'instruction du ''thread'' est déjà pleine, auquel cas un autre ''thread'' est choisi. Précisons que le grand nombre de registres et de ''threads'' fait qu'un ''scoreboard'' classique, tel que décrit dans les cours d'architecture des ordinateurs, devient rapidement impraticable. Aussi, une implémentation alternative est utilisée, bien que les détails ne soient pas connus à ce jour. Quelques brevets donnent des détails d'implémentation, mais on ne sait pas s'ils sont à jour. Les curieux peuvent tenter de lire le brevet américain numéro #7,634,621, nommé ''"Register File Allocation"'', déposé par NVIDIA durant décembre 2009. [[File:FGMT sur un processeur de shaders.png|centre|vignette|upright=2|FGMT sur un processeur de shaders]] L'usage d'un ''scoreboard'' permet de nombreuses optimisations, qui portent notamment sur les lectures, qui interagissent avec le FGMT. L'optimisation en question s'appelle les '''lectures non-bloquantes'''. Elle fonctionne si la lecture est suivie par d'autres instructions qui n’accèdent pas à la mémoire, par exemple des instructions de calculs. L'idée est d'exécuter ces instructions en avance, pendant que la lecture récupère la donnée. C'est possible car la lecture utilise l'unité mémoire et le cache, mais laisse libre les unités de calcul et les registres. En clair, pendant qu'on lit une texture, on fait des calculs en parallèle dans les ALU. Mais la technique n'est possible que si les instructions de calcul n'utilisent pas la donnée en cours de lecture. Heureusement, le ''scoreboard'' détecte si cela arrive en surveillant les registres. La lecture charge une donnée dans un registre, appelé le registre de destination (sous entendu, de destination de la lecture). Les instructions qui n'utilisent pas ce registre peuvent s'exécuter sans problèmes : elles sont indépendantes de la lecture. Mais dès qu'une instruction souhaite lire ou écrire dans ce registre, elle est bloquée par le ''scoreboard'' et le processeur change de ''thread''. Détecter les dépendances demande juste de mémoriser le registre de destination dans un registre temporaire, et de regarder si l'instruction à exécuter utilise ce registre. Une différence avec le CGMT est le moment où un ''thread'' est bloqué par une lecture. Avec des lectures bloquantes, un ''thread'' est bloqué dès que la lecture est lancée. Et ce même si les instructions suivantes sont indépendantes de la lecture. Par contre, avec les lectures non-bloquantes, un ''thread'' n'est pas bloqué à ce moment-là. Il continue son exécution jusqu'à ce qu'une instruction accède à la donnée lue. Si la lecture s'est déjà terminée, alors la donnée est disponible et le ''thread'' continue de s'exécuter. Mais si la lecture est encore en cours, alors le processeur bloque l'instruction et change de ''thread''. Une autre optimisation possible est l'usage de l''''émission multiple'''. Avec elle, le ''scoreboard'' peut émettre deux instructions lors du même cycle d'horloge, si elles utilisent des unités de calcul différentes. Les deux instructions doivent faire partie du même ''thread'', mais certains GPU acceptent qu'elles soient de deux ''threads'' différents, tout dépend du GPU. Par exemple, il est possible qu'une instruction d'un ''thread'' utilise l'unité de calcul SIMD, alors que l'autre lance une lecture de texture. Ou encore, les deux peuvent lancer des calculs SIMD, mais dans deux unités SIMD séparées. ===L'encodage explicite des dépendances sur les GPU post-2010=== Depuis environ 2010, les GPU n'utilisent plus de ''scoreboard'' proprement dit. A la place, les GPU modernes déportent la détection des dépendances de données à la compilation. L'idée est que chaque instruction contient quelques bits pour dire au processeur : tu peux lancer 1, 2, 3 instructions à la suite sans problème. La technique porte le nom d''''anticipation de dépendances explicite''' (''Explicit-Dependence lookahead''). Un exemple historique assez ancien est le processeur Tera MTA (''MultiThreaded Architecture''), qui utilisait cette technique. Les GPU NVIDIA modernes utilisent plusieurs bits par instruction pour gérer les dépendances de données. Un premier mécanisme est utilisé pour bloquer l'émission d'une nouvelle instruction pendant x cycles. Il utilise un '''''stall counter''''', qui mémorise le nombre de cycles d'attente. Une instruction peut initialiser le ''stall counter'' avec une valeur de base, qui indique combien de cycles attendre. Le ''stall counter'' est décrémenté à chaque cycle d'horloge et une nouvelle instruction s'exécute seulement quand le compteur atteint 0. Il est utilisé quand une instruction productrice prenant X cycles est suivie par une instruction consommatrice, le ''stall counter'' est alors initialisé à la valeur X. La méthode précédente ne fonctionne que pour les instructions dont le compilateur peut prédire la durée. Les accès mémoire et certains calculs complexes ne sont pas dans ce cas. Pour les gérer, les GPU modernes utilisent un autre mécanisme, basé sur des '''compteurs de dépendances'''. Il y en a entre 5 et 10 selon le GPU. Une instruction dite productrice réserve un de ces compteurs, l'incrémente quand elle démarre son exécution/est émise, le décrémente quand la dépendance est résolue. Les instructions consommatrices, qui utilisent le résultat de l'instruction productrice, attendent que le compteur tombe à zéro pour s'exécuter. Elles précisent quels compteurs regarder avec un '''masque de compteurs de dépendance''', encodé directement dans l'instruction elle-même. Précisément, chaque instruction productrice se réserve deux compteurs, pour gérer les trois types de dépendances de données (RAW, WAR et WAW). Le premier compteur est décrémenté quand le résultat est écrit dans les registres, ce qui gère naturellement les dépendances RAW et WAW. Le second est décrémenté quand l'instruction a lu ses opérandes, ce qui gère les dépendances WAR. Les autres instructions regardent l'un ou l'autre des compteurs selon leur situation. Il arrive que le switch de ''thread'' soit déclenché par des bits intégrés dans l'instruction. Par exemple, sur les GPU NVIDIA modernes, chaque instruction contient un bit '''''yield''''' qui indique qu'il faut changer de ''thread'' une fois l'instruction émise. En clair, il indique que l'instruction risque de durer longtemps, a des dépendances avec la mémoire ou autre chose qui fait qu'il est préférable de changer de ''thread''. ==Le banc de registres d'un processeur de ''shader''== Les GPU disposent d'un grand nombre de registres. Les normes de DirectX et Open GL imposent que les shaders modernes gèrent au moins 4096 registres généraux par instance de shader, avec des registres spécialisés en plus. En soi, 4096 est énorme ! Mais au-delà de ces normes, le FGMT implique de dupliquer des registres par le nombre de ''threads hardware'' simultanés. Avec le FGMT, les registres devront être dupliqués pour que chaque ''thread'' ait ses propres registres rien qu'à lui. Sans cela, impossible de passer rapidement d'un ''thread'' à l'autre à chaque cycle. Maintenant, faisons quelques calculs d'épiciers. Un processeur de ''shader'' peut exécuter entre 16 et 32 ''threads''/''warps'', ce qui multiplie le nombre de registres par 16/32. En multipliant par les 4096 registres nécessaires, cela fait 128 kilooctets de mémoire rien que pour les registres. Et c'est pour un seul cœur ! Si on multiplie par le nombre de cœurs, on trouve que les cartes graphiques modernes ayant plusieurs processeurs de ''shaders'' ont facilement entre 32 768 et 65 536 registres de 32 bits, ce qui est énorme ! Il y a plus de mémoire gaspillée dans les registres que dans le cache L1 ou L2 ! Et ce grand nombre de registres par cœur pose quelques problèmes. Les registres sont regroupés dans une petite mémoire SRAM, adressable, appelée le '''banc de registre'''. Et comme toutes les mémoires, plus ce banc de registres est grand, plus il est lent. En conséquence, lire un opérande dans les registres prend beaucoup de temps. Du moins, c'est le cas sans optimisations. Et les GPU implémentent de nombreuses parades pour limiter le nombre de registres réellement présents dans leur silicium. ===L'allocation dynamique/statique des registres par ''thread''=== Les GPU modernes n'implémentent pas le nombre maximal de registres demandés. Par exemple, si je prends les GPU AMD de type RDNA 4, il peuvent gérer 16 ''threads'' hardware simultanés, chacun ayant accès à 256 registres, registres faisant 128 octets chacun. On s'attend à avoir un banc de registre de 16 * 256 * 128 octets, soit 512 Kilo-octets. En somme, un banc de registre de la taille du cache L1. Sauf que les GPUs en question intègrent moins de registres que prévu ! Ils ont précisément une taille de 192 kilo-octets, soit 96 registres pour chacun des 16 ''threads''. En effet, 256 registres est un nombre maximal, que la plupart des ''shaders'' n'utilise pas totalement. La plupart des ''shaders'' utilise entre 64 et 128 registres, rarement moins, rarement plus. Aussi, le GPU partitionne le banc de registres à la demande entre les ''threads'', en leur donnant seulement une partie des registres. Le partitionnement peut être pseudo-statique, à savoir que le banc de registre est découpé en parts égales pour chaque ''thread'', ou dynamique avec un nombre de registre variant d'un ''thread'' à l'autre, selon les besoins. Prenons l'exemple d'un '''partitionnement pseudo-statique''', avec l'exemple des GPU AMD RDNA 1 et 2. Leur banc de registre fait 1024 registres, de 128 octets chacun, soit 128 KB au total. Le GPU gère 16 ''threads'' simultanés maximum. Avec un seul ''thread'' d'exécuté, le ''thread'' unique peut utiliser les 1024 registres du banc de registre pour lui tout seul. Avec deux ''threads'', chacun aura droit à 512 registres, soit la moitié du cas précédent. Avec 16 ''thread'' simultanés, chaque ''thread'' a accès à 64 registres, pas plus. Et ainsi de suite : le nombre de registre par ''thread'' est égal à la taille du banc de registre divisée par le nombre de ''threads''. A l'heure où j'écris ces lignes, courant 2025, les GPU Intel se contentent d'un partitionnement statique très limité. Avant les GPU d'architecture Battlemage, il n'y avait pas de partitionnement du banc de registre, tous les ''threads'' avaient 128 registre à leur disposition. Les GPU Battlemage et ses successeurs ont introduit un partitionnement limité, avec deux modes : un mode sans partitionnement où tous les ''threads'' ont accès à 128 registres, un mode avec partitionnement qui divise le nombre de ''thread'' par deux et leur donne chacun 256 registres. Pas de possibilité de diviser plus le nombre de ''threads''. Les GPU AMD et NVIDIA sont eux plus compétents niveau partitionnement statique. Par exemple, les GPU RDNA 4 supportent les partitionnements suivants : * 16 ''threads'' avec 96 registres chacun ; * 12 ''threads'' avec 120 registres chacun ; * 10 ''threads'' avec 144 registres chacun ; * 9 ''threads'' avec 168 registres chacun ; * 8 ''threads'' avec 192 registres chacun ; * 7 ''threads'' avec 216 registres chacun ; * 6 ''threads'' avec 240 registres chacun ; * 5 ''threads'' avec 256 registres chacun. Le partitionnement pseudo-statique est simple à implémenter, il ne demande pas beaucoup de circuits pour fonctionner. Il est rapide et a de bonnes performances pour le rendu graphique en rastérisation. La raison est qu'en rastérisation, les différents ''threads'' sont souvent des copies/instances d'un même ''shader'' qui travaillent sur des données différentes. Leur donner le même nombre de registres colle bien avec cet état de fait. Cependant, si les différents ''threads'' sont des ''shaders'' différents, les choses ne sont pas optimales. Un ''shader'' utilisera plus de registres que l'autre, leur donner le même nombre de registres n'est pas optimal. Par exemple, imaginons que l'on a deux shaders, nommés shaders 1 et 2, aux besoins différents, l'un étant gourmand en registres et l'autre très économe. Dans ce cas, il faudrait partitionner le banc de registre pour donner plus de registre au premier et moins au second. Il s'agit là d'un '''partitionnement dynamique'''. Le partitionnement dynamique est plus optimal pour gérer des ''shaders'' déséquilibrés niveau registres, mais a une implémentation matérielle plus complexe. Il a été introduit assez tard, car il a fallu attendre que le rayctracing se démocratise. En effet, le partitionnement dynamique du banc de registre est surtout utile pour le raytracing. Exécuter simultanément des shaders déséquilibrés en registres est peu fréquent avec la rastérisation, beaucoup plus courant avec le raytracing. NVIDIA et AMD ont des implémentations différentes du partitionnement dynamique. Les GPU AMD RDNA 4 allouent un nombre minimal de registre à chaque ''thread'', mais ils peuvent demander d'avoir accès à plus de registres si besoin. Quand un ''thread'' a besoin de plus de registres, il exécute une instruction dédiée, qui sert à demander plus de registres, ou au contraire à en libérer s'ils sont inutilisés. La demande d'allocation de nouveaux registres se fait par blocs de 16 à 32 registres, suivant comment est configuré le processeur. Précisons que l'instruction d'allocation n'est disponible que pour les ''compute shaders'' et pas les ''shaders'' graphiques. L'instruction d'allocation de registre précédent peut échouer dans certains cas. Si assez de registres sont disponibles, à savoir inutilisés par d'autres ''threads'', l'instruction réussit. Dans le cas contraire, elle échoue et le ''shader'' est mis en pause avant de retenter cette demande plus tard. Le résultat de l'instruction, échec ou réussite, est mémorisé dans le registre d'état. La technique a pour défaut que certaines situations peuvent mener à un blocage complet du processeur, où chaque ''thread'' ne peut plus poursuivre son exécution, faute de registres disponibles. Des méthodes pour éviter cette situation sont implémentés sur ces GPU, mais la documentation n'est pas très explicite. Sur les GPU NVIDIA, il y a aussi une instruction d'allocation de registre, mais elle fonctionne différemment. Elle permet d'échanger des registres entre ''threads''. Une première différence est que tous les ''threads'' commencent avec une allocation égale des registres. Les ''threads'' démarrent tous avec le même nombre de registres. Un ''thread'' peut libérer des registres, qui sont alors alloués à un autre ''thread'', le ''thread'' en question pouvant être choisit par le ''thread'' qui libère les registres. ===Le banc de registre est multiport de type externe=== Le banc de registre doit permettre de lire deux vecteurs SIMD par opération, soit deux lectures simultanées. Pour cela, le banc de registre contient deux ports de lecture, chacun permettant de lire un opérande dans le banc de registre. Mais plus le nombre de ports augmente, plus la consommation énergétique du banc de registre augmente, sans compter que celui-ci devient plus lent. Les processeurs peuvent utilisent des bancs de registres ayant réellement deux ports par banc de registre. Un port de lecture est implémenté avec un composant appelé un multiplexeur, connecté à tous les registres. [[File:Mémoire multiport faite avec des MUX-DEMUX.png|centre|vignette|upright=2|Mémoire multiport faite avec des MUX-DEMUX]] Les GPU ne peuvent pas se permettre un tel luxe. Leur banc de registre doit alimenter plusieurs unités de calcul en même temps, en parallèle. Le nombre de ports serait plus proche de 4 à 10 ports de lectures. La solution précédente aurait un budget en transistor et un budget thermique trop important. À la place, ils utilisent une autre méthode : ils simulent un banc de registre à plusieurs ports avec un ou plusieurs bancs de registres à un port. On parle alors de '''multiport externe'''. Il existe plusieurs méthodes de multiport externe. Mais celle utilisée sur les GPU simule un banc de registre multiport à partir de plusieurs bancs de registres à un port. Le banc de registre est en réalité formé de plusieurs banques, de plusieurs bancs de registre séparés, chacun mono-port. L'idée est que si l'on accède à deux banques en même temps, on peut lire deux opérandes, une par port/banc de registre. Par contre, si les deux opérandes à lire sont la même banque, il y a un '''conflit d'accès aux banques'''. [[File:Mémoire à multiports par banques.png|centre|vignette|upright=2|Mémoire à multiports par banques.]] ===L'''Operand Collector'' et les caches de ''register reuse''=== Sans conflit d'accès à une banque, les deux opérandes sont disponibles immédiatement. Par contre, en cas de conflit d'accès aux banques, les deux opérandes sont lus l'une après l'autre. En clair, le premier opérande doit être mis en attente quelque part pendant que la seconde est en cours de lecture. Et le problème survient souvent, surtout avec les opérations FMA qui utilisent trois opérandes, encore plus avec les rares opérations qui demandent 4 à 5 opérandes. Pour gérer les conflits d'accès aux banques, les GPU utilise un circuit dédié, appelé le '''collecteur d'opérandes''' (''operand collector''). Le rôle du collecteur d'opérandes est d'accumuler les opérandes en attente, son nom est assez transparent. Il accumule les opérandes en attente, puis les envoie aux unités de calcul quand elles sont toutes prêtes, toutes lues depuis le banc de registres. Les opérandes sont mis en attente dans des entrées, qui contiennent la donnée, l'identifiant du ''thread''/''warp'' pour ne pas confondre des opérandes entre ''threads'', et deux bits d'occupation. Les deux bits d'occupation indiquent si l'entrée est vide, réservée pour un opérande en cours de lecture ou occupée par un opérande. Le collecteur d'opérande est souvent accompagné de '''registres temporaires''', qui mémorisent le résultat d'une instruction précédente. Il y a un registre temporaire par ALU, le résultat fournit par une ALU est mémorisé dans le registre temporaire associé. Une instruction peut lire une opérande dans un registre temporaire, ce qui permet de lire le résultat d'une instruction précédente sans passer par le banc de registres. Le collecteur d'opérande est alors configuré pour récupérer les opérandes adéquats dans les registres temporaires adéquats. : Pour faire une comparaison avec les processeurs modernes, ces registres sont une forme de ''data forwarding'', de contournement, mais qui est rendue explicite pour le logiciel. Une optimisation des GPU récent vise à réduire les accès aux bancs de registres en utilisant une mémoire cache spécialisée. Il s'agit de l'''operand reuse cache'', aussi appelés '''''register reuse cache'''''. L'idée est que quand un opérande est lu depuis le banc de registres, elle peut être stockée dans ce cache pour des utilisations ultérieures. Les architectures Volta, Pascal et Maxwell disposent de 4 caches de ce type, chacun stockant 8 données/opérandes/résultats Il s'agit en réalité de pseudo-caches, car ils sont partiellement commandés par le logiciel. Une instruction précise qu'un opérande doit être stocké dans un ''register reuse cache'', pour une utilisation ultérieure. Pour cela, elle incorpore quelques bits pour préciser qu'elle doit être placée dans le cache. Les bits font en quelque sorte partie du mode d'adressage. Si l'instruction immédiatement suivante lit ce registre dans le même ''slot'' d'opérande lira l'opérande dans le cache. La technique est donc assez limitée, mais elle a des résultats pas négligeables. Les ''register reuse cache'' et le collecteur opérandes sont sans doute fusionnés en un seul circuit, plutôt que d'utiliser deux circuits séparés. La raison est que les deux doivent mémoriser des opérandes les mettre en attente pour une utilisation ultérieure, et sont placés juste après le banc de registre. NVIDIA a publié deux brevets à propos de ces deux techniques, mais rien n'indique que c'est exactement cette technique qui utilisée dans les cartes modernes. Il faut dire que le nombre de banques a changé suivant les cartes graphiques. * [https://patents.google.com/patent/US7834881B2/en Operand collector architecture ] * [https://patents.google.com/patent/US20130159628A1/en Methods and apparatus for source operand collector caching ] <noinclude> {{NavChapitre | book=Les cartes graphiques | prev=Les processeurs de shaders | prevText=Les processeurs de shaders | next=Les caches d'un processeur de shader | netxText=Les caches d'un processeur de shader }}{{autocat}} </noinclude> ieerqwf1thngwnbdxs8jo8sglmpurfa 765156 765155 2026-04-26T20:17:15Z Mewtow 31375 /* Le banc de registre est multiport de type externe */ 765156 wikitext text/x-wiki La conception interne (aussi appelée microarchitecture) des processeurs de ''shaders'' possède quelques idiosyncrasies. Mais avant d'expliquer lesquelles, nous devons prévenir d'une chose importante : dans ce chapitre, nous ne parlerons que des GPU de l'époque DirectX 10 et après, pas des GPU de l'époque DirectX 9. La raison est que leur jeu d'instruction a franchement évolué, avec le passage d'architectures VLIW à des architectures SIMD. Et cela a eu des conséquences assez profondes sur le jeu d'instruction et leur microarchitecture. Nous n'allons parler des GPU de type SIMD dans ce chapitre. Un chapitre dédié sera consacré aux GPU de type VLIW. Pour rappel, un processeur de shader supporte plusieurs types d'instructions. Au minimum, il supporte des ''instructions SIMD''. Mais il peut aussi gérer des ''instructions scalaires'', à savoir qu'elles travaillent sur des entiers/flottants isolés, en dehors d'un vecteur SIMD. Typiquement, il y a plusieurs types d'instructions scalaires : les calculs entiers, les calculs flottants simples, les calculs flottants complexes dit transcendantaux (calculs trigonométriques, des exponentielles, des logarithmes, des racines carrées ou racines carrées inverse). En clair, un processeur de shader doit savoir faire des calculs. Et il a des circuits dédiés pour ça, appelés des unités de calcul. Les unités de calcul sont souvent classées en deux types : les ALU et les FPU. Les premières font des calculs sur des opérandes entiers, alors que les secondes font des calculs flottants. Au passage, le terme ALU signifie ''Arithmetic and Logic Unit'', alors que FPU signifie ''Floating Point Unit'', les deux termes étant assez parlants. Mais un GPU utilise non seulement des ALU et des FPU, mais aussi des regroupements de plusieurs ALU/FPU. Voyons cela en détail. ==Les unités de calcul d'un processeur de shader SIMD== Un processeur de shader contient un grand nombre d'unités de calcul très différentes. Le cœur est une unité de calcul SIMD, qui se charge des instructions SIMD. A cela, il faut souvent rajouter une ALU ou une FPU, parfois des unités de calcul flottantes spécialisées pour les opérations complexes, comme des opérations trigonométriques ou transcendantales. ===Les unités de calcul SIMD=== [[File:SIMD2.svg|vignette|Une unité de calcul SIMD.]] Un processeur de shader incorpore une unité de calcul SIMD, qui effectue plusieurs calculs en parallèle. Elle regroupe plusieurs ALU flottantes regroupées ensemble et avec quelques circuits pour gérer les débordements et d'autres situations. En théorie, une unité de calcul SIMD regroupe autant d'unité de calcul qu'il y a d'entiers/flottants dans un vecteur. Par exemple, pour additionner deux vecteurs contenant chacun 16 flottants, il faut utilise 16 additionneurs flottants. Ce qui fait qu'une opération sur un vecteur est traité en une seule fois, en un cycle d'horloge. Une contrainte importante est que toutes les sous-ALU effectuent la même opération : ce ne sont pas des ALU séparées qu'on peut commander indépendamment, mais une seule ALU regroupant des circuits de calcul distinct. Le cout en circuit est d'autant plus grand que les vecteurs sont longs et le cout est approximativement proportionnel à la taille des vecteurs. Entre des vecteurs de 128 et 256 bits, l'unité de calcul utilisera globalement deux fois plus de circuits avec 256 bits qu'avec 128. Même chose pour les registres, mais c'est là un cout commun à toutes les architectures. Il y a quelques unités de calcul SIMD où le calcul se fait en deux fois, car on n'a que la moitié des unités de calcul. Par exemple, pour un vecteur de 32 flottants, on peut utiliser 16 unités de calcul, mais le temps de calcul se fait en deux cycles d'horloge. Les opérations sur les vecteurs sont donc faites en deux fois : une première passe pour les 16 premiers éléments, une seconde passe pour les 16 restants. L'implémentation demande cependant qu'une instruction de calcul soit décodée en deux micro-opérations. Par exemple, une instruction SIMD sur des vecteurs de 32 éléments est exécutée par deux micro-instructions travaillant sur des vecteurs de 16 éléments. On économise ainsi pas mal de circuits, mais cela se fait au détriment de la performance globale. L'avantage est que cela se marie bien avec l'abandon des opérations pour lesquelles masques dont tous les bits sont à 0. Par exemple, prenons une instruction travaillant sur 16 flottants, exécutée en deux fois sur 8 flottants. Si le masque dit que les 8 premières opérations ne sont pas à exécuter, alors l'ALU ne fera que le calcul des 8 derniers flottants. Pour cela, le décodeur doit lire le registre de masque lors du décodage pour éliminer une micro-instruction si besoin, voire les deux si le masque est coopératif. ===Plusieurs unités SIMD, liées au format des données=== Il faut préciser qu'il y a une séparation entre unités SIMD flottantes simple et double précision. Pour le dire plus clairement, il y a des unités SIMD pour les flottants 32 bits, d'autres pour les flottants 64 bits, et même d'autres pour les flottants 16 bits. Les flottants 64 bits sont utilisés dans les applications GPGPU, les flottants 16 et 32 bits le sont dans le rendu 3D, et les flottants 16 bits pour tout ce qui lié à l'IA. Malheureusement, on doit utiliser des ALU flottantes séparées pour chaque taille, le format des flottants n'aidant pas. Depuis plus d'une décennie, les cartes graphiques ont des unités SIMD à la fois pour les calculs entiers et flottants. Elles sont censées être séparées. Pour NVIDIA, avant l'architecture Turing, les unités SIMD entières et flottantes sont décrites comme séparées dans leurs ''white papers'', avec des unités INT32 et FLOAT32 séparées, combinées à d'autres unités de calcul. L'architecture Volta a notamment des unités INT32, FLOAT32 et FLOAT64 séparées. A partir de l'architecture Ampere, il semblerait que les unités SIMD soient devenues capables de faire à la fois des calculs flottants et entiers, pour la moitié d'entre elles. Mais il se pourrait simplement qu'elles soient physiquement séparées, mais reliées aux reste du processeur de manière à ne pas être utilisables en même temps. Il est cependant possible que sur d'anciennes architectures, les unités entières et flottantes partagent des circuits, notamment pour ce qui est de la multiplication. En effet, une unité de calcul flottante contient des circuits pour faire des calculs entiers pour additionner/multiplier les mantisses et les exposants. Il est possible d'utiliser ce circuit Un exemple est le cas de l'architecture GT200 d'NVIDIA, sur laquelle les "pseudo-ALU" entières SIMD étaient limitées à des multiplications d'opérandes 24 bits, ce qui correspond à la taille d'une mantisse d'un flottant 32 bits. Le design exact des ALU n'est pas connu. ===Les unités de calcul scalaires=== Les GPU modernes incorporent une '''unité de calcul entière scalaire''', séparée de l'unité de calcul SIMD. Elle gère des calculs scalaires, à savoir qu'elle ne travaille pas sur des vecteurs. Elle gère divers calculs, comme des additions, soustractions, comparaisons, opérations bit à bit, etc. Elle exécute les instructions de calcul entière sur des nombres entiers isolés, de plus en plus fréquentes dans les shaders. Elle est parfois accompagnée d'une unité de calcul pour les branchements. Par branchements, on veut parler des vrais branchements similaires à ceux des CPU, qui effectuent des tests sur des entiers et effectuent des branchements conditionnels. Ils n'ont rien à voir avec les instructions à prédicat qui elles sont spécifiques à l'unité de calcul vectorielles. Ce sont des instructions séparées, totalement distinctes Les processeurs de shaders incorporent aussi une '''unité de calcul flottante scalaire''', utilisée pour faire des calculs sur des flottants isolés. L'unité de calcul gère généralement des calculs simples, comme les additions, soustractions, multiplications et divisions. Il s'agit typiquement d'une unité de calcul spécialisée dans l’opération ''Multiply-And-Add'' (une multiplication suivie d'une addition, opération très courante en 3D, notamment dans le calcul de produits scalaires), qui ne gère pas la division. L'unité de calcul flottante est souvent accompagnée d'une unité de calcul spécialisée qui gère les calculs transcendantaux, avec une gestion des calculs trigonométriques, de produits scalaires ou d'autres opérations. Elle porte le nom d''''unité de calcul spéciale''' (''Special Function Unit''), ou encore d'unité de calcul transcendantale, et elle a d'autres appellations. Elle est composée de circuits de calculs accompagnés par une table contenant des constantes nécessaires pour faire les calculs. Rappelons que les registres SIMD et les registres scalaires sont séparés et ne sont pas adressés par les mêmes instructions. Les registres scalaires sont placés dans un banc de registre physiquement séparé du banc de registres SIMD. Le banc de registre scalaire est relié à sa propre ALU scalaire, il y a vraiment une séparation physique entre registres scalaires et SIMD. Il existe cependant un système d'interconnexion qui permet d'envoyer un scalaire aux unités SIMD, ce qui est utile pour les opérations de produits scalaire ou autres. ==L'intérieur d'un processeur de shader== : Cette section sera surtout des rappels, pour ceux qui ont déjà lu un cours d'architecture des ordinateurs, et connait la notion de pipeline. En plus des unités de calcul, un processeur contient d'autres circuits, et un processeur de shader ne fait pas exception. Un processeur est composé de quatre circuits principaux : * les unités de calcul, qui font des calculs et d'autres opérations ; * les registres pour mémoriser les opérandes des calculs et leurs résultats ; * une unité mémoire pour échanger des données entre VRAM et registres ; * une unité de contrôle qui exécute les instructions. Les unités de calcul, les registres et l'unité mémoire sont souvent regroupés sous le terme de '''chemin de données'''. Le terme dit clairement que c'est la partie du processeur qui gère les données, les manipule, fait des calculs, etc. A côté du chemin de données, il y a une unité de contrôle, qui commande ce chemin de données pour qu'il fasse les instructions demandées. L'unité de contrôle lit les instructions depuis la mémoire vidéo, et configure le chemin de données, pour qu'il exécute l'instruction demandée. L'unité de contrôle sera détaillée dans la section suivante. ===Le chemin de données d'un processeur de shader=== Un processeur de shader contient au minimum une unité SIMD, et une unité scalaire. Il est possible d'utiliser les deux en même temps, grâce aux instructions à ''co-issue''. Elles sont surtout utiles pour exécuter des instructions scalaires en parallèle d'instruction SIMD, mais guère plus. Notez que j'ai dit : "au minimum une unité SIMD et une unité scalaire", car les processeurs de shaders modernes dupliquent les unités de calcul, pour des raisons qu'on expliquera dans la suite du chapitre. Il n'est pas rare qu'un processeur de shader dispose d'une dizaine d'unités SIMD et de 2 ou 3 unités scalaires. Nous avons vu les registres dans le chapitre précédent, aussi je ne vais pas revenir dessus. Je vais juste préciser que les registres sont regroupés dans des '''bancs de registres'''. Ce sont de petites mémoires dont chaque adresse contient un registre. En général, il y a un banc de registre pour les scalaires et un autre pour les vecteurs SIMD. Les deux sont séparés car ils ne sont pas utilisés par les mêmes instructions. Et c'est plus pratique à implémenter. L''''unité d'accès mémoire''' s'occupe des lectures et écriture en général, et elle prend en charge les accès aux textures, le filtrage de textures et tout un tas de fonctionnalités liées aux textures. La seule différence entre un accès aux textures et une lecture/écriture en mémoire est que les circuits de filtrage de texture sont contournés dans une lecture/écriture normale. Dans ce qui suit, nous allons l'appeler l'unité de texture par souci de simplification. ===L'unité de contrôle d'un processeur de shader=== L'unité de contrôle d'un GPU a quelques petites différences avec celles d'un CPU moderne. Les unités de contrôle des GPU n'utilisent pas les optimisations des CPU modernes, tant utiles pour du calcul séquentiel : pas d’exécution dans le désordre, de renommage de registres, et autres techniques avancées. A la place, elles utilisent des techniques alternatives qu'on décrira pdans la suite du chapitre, qui sont peu gourmandes en transistors. En conséquence, les unités de contrôle sont très simples, prennent peu de place, utilisent peu de transistors. La majeure partie du processeur est dédié aux unités de calcul et aux registres. [[File:Cpu-gpu.svg|centre|vignette|upright=2.0|Comparaison entre l'architecture d'un processeur généraliste et d'un processeur de shaders.]] Un processeur de shader a une unité de contrôle assez classique, composée de plusieurs circuits. * une unité de ''Fetch'' qui calcule l'adresse de la prochaine instruction ; * un cache d'instruction dans lequel on récupère la prochaine instruction, en présentant son adresse ; * une unité de décodage d'instruction, qui traduit l'instruction en signaux de commande à destination de l'unité de calcul et des registres ; * une unité d’''issue'', aussi appelée le ''scoreboard'', qu'on détaillera dans ce qui suit. Le tout est illustré ci-dessous, avec le chemin de données. Vous remarquerez que dans le chemin de données, il y a aussi une unité pour enregistres les résultats dans les registres, qui effectue pas mal de traitements importants qu'on ne peut pas détailler ici. Vous remarquerez aussi que l'unité qui calcule l'adresse de la prochaine instruction est un peu complexe. Mais laissons cela de côté pour le moment. [[File:Vortex microarchitecture.png|centre|vignette|upright=2.5|Exemple de microarchitecture d'un processeur de shader.]] ===Le pipeline d'un processeur de shader=== Un point important est que les processeurs de shaders utilisent la technique du '''pipeline'''. Les unités vues précédemment fonctionnent indépendamment des autres. Elles n'attendent pas que l'instruction soit terminée pour commencer à traiter la suivante. Concrètement, la première unité traite l'instruction courante, pendant que la suivante traite l'instruction précédente, et ainsi de suite : * la première unité calcule l'adresse de l'instruction numéro N; * le cache d'instruction lit l'instruction numéro N-1 ; * l'unité de décodage décode l'instruction numéro N-2 ; * le ''scoreboard'' analyse l'instruction numéro N-3 ; * les unités de calcul exécutent l'instruction numéro N-4 ; * l'unité d'enregistrement écrit le résultat de l'instruction numéro N-5 dans les registres. Faire ainsi maximise les performances, car cela permet d'exécuter plusieurs instructions en même temps, à des étapes différentes. Le débit d'instruction est maximisé, les shaders s'exécutent plus vite. Le nombre d'étapes exact dépend du processeur. Il arrive que des processeurs fusionnent des étapes en un seul circuit. Par exemple, l'unité de décodage et le ''scoreboard'' peuvent être regroupés dans une seule étape, de même que l'accès aux registres et le calcul dans l'ALU. D'autres processeurs scindent certaines étapes en plusieurs sous-circuits séparés. Par exemple, il peuvent scinder leurs ALU en plusieurs sous-ALU, chacune exécutant un morceau de calcul. Et cela permet d'expliquer pourquoi un processeur de ''shaders'' SIMD contient beaucoup d'unités de calcul, identiques, ou non. Les instructions prennent plusieurs cycles d'horloge à s’exécuter, ce qui fait qu'une instruction occupe une unité de calcul pendant 2 à 20 cycles, rarement plus. Cependant, le processeur peut démarrer une nouvelle instruction par cycle d'horloge. Et cela permet malgré tout de démarrer une nouvelle instruction dans une unité de calcul libre. Pendant qu'une instruction en est à son second ou troisième cycle dans une ALU, il est possible de démarrer une nouvelle instruction dans une ALU inoccupée, sans voir recours à la ''co-issue''. s'il y en a une. Par exemple, reprenons l'exemple de l'unité de ''vertex shader'' de la Geforce 6800, mentionné au-dessus. Elle dispose d'une unité de calcul SIMD MAD, et d'une unité de texture, ainsi que d'une unité de calcul scalaire transcendantale. Il en en théorie possible de faire en même temps : un calcul dans l'ALU SIMD, une lecture de texture dans l'unité mémoire, un calcul trigonométrique dans l'unité transcendantale. Il suffit de lancer la lecture de texture à un cycle, l’instruction MAD au cycle suivant, et l'instruction spéciale deux cycles après. ===Le ''scoreboard'' d'un processeur de shader=== Exécuter plusieurs instructions en même temps pose un problème quand deux instructions consécutives sont dépendantes l'une de l'autre. Un cas classique est quand une instruction a besoin du résultat de la précédente. Dans ce cas, l'instruction accède aux registres alors que la première n'a pas encore écrit dedans, ce qui pose un problème. Il s'agit d'une dépendance dite RAW (''Read After Write'') typique, que la carte graphique doit gérer automatiquement. La seconde instruction ne doit pas démarrer tant que la précédente n'a pas enregistré son résultat dans les registres. Le ''scoreboard'' gère ce genre de problèmes. Il détecte les dépendances entre instructions et les gère sans intervention extérieure. Pour cela, il vérifie si les opérandes de l'instruction sont en cours de calcul. Pour cela, il regarde quels registres l'instruction lit/écrit, et vérifie s'ils sont en cours d'utilisation. Pour savoir quels registres sont en cours d'utilisation, rien de plus simple : quand il démarre une instruction, le ''scoreboard'' marque ses registres comme en cours d'utilisation. Le ''scoreboard'' se contente de bloquer l'instruction tant qu'elle ne peut pas s'exécuter. Et les instructions suivantes sont aussi bloquées dans l'étage où elles sont. Rien ne progresse dans le pipeline tant que l'instruction fautive est bloquée. Cependant, les processeurs de shaders disposent de plusieurs optimisations concernant ce genre de situations. Voyons cela en détail. Et au-delà de ça, le ''scoreboard'' bloque l'exécution d'une instruction si les conditions ne sont pas remplies. Notamment, il vérifie qu'il y a une unité de calcul libre pour exécuter l'instruction. Si ce n'est pas le cas, l'instruction est bloquée. Au-delà de ça, il existe d'autres dépendances liées au fait que deux instructions utilisent les mêmes registres. Mais avec 4096 registres par ''shader'', elles sont plus rares, ce qui fait qu'on les laisse volontairement de côté. ===Exemple et résumé final=== Pour finir, nous allons voir un exemple final, celui des GPU Radeon X1000 series, de microarchitecture R500, Terascale. Leur microarchitecture est résumée dans le schéma ci-dessous. La portion gauche du schéma montre plusieurs choses. Le GPU contient un processeur de commande, appelé l'''ultra threaded dispatcher'' sur ces GPU. Il alimente plusieurs processeurs de shaders, ici appelés des ''compute unit'' (CU). De nombreux circuits sont partagés entre plusieurs processeurs. Par exemple, ils partagent un cache L2, dans lequel ils viennent récupérer les données nécessaires. Le GPU contient aussi des contrôleurs mémoire, qui lisent ou écrivent des données en mémoire vidéo. Les contrôleurs mémoire servent surtout d'interface entre la mémoire vidéo et le cache L2, mais ils peuvent aussi envoyer des données lue aux processeurs de shaders. Le focus de droite montre ce qu'il y a dans un processeur de shader. Déjà, l'unité de contrôle est en haut et est nommée ''Fetch, Decode, Schedule'' : ''Schedule'' est un synonyme de ''Issue''. L'unité mémoire est appelée la ''Load Store Unit'' (LSU), elle communique avec la mémoire vidéo et les registres. A côté, on trouve une unité de calcul SIMD et une unité de calculs scalaire. Pour ce qui est des mémoires, elle montre qu'il y a une petite mémoire locale généraliste, complétée avec deux bancs de registres : un pour les données scalaires, un pour les vecteurs SIMD. * Les registres pour les scalaires sont appelés les ''Scalar General Purpose Registers'' (GPR). * Les registres pour les vecteurs SIMD sont appelés les ''Vector General Purpose Registers'' (VGPR). * La mémoire locale généraliste, appelée la mémoire partagée (LDS). Niveau interconnexions, les flèches montrent plusieurs choses. Premièrement, l'unité mémoire est reliée aux bancs de registres, ainsi qu'à la mémoire locale. Les accès à la mémoire locale passent par l'unité mémoire, qui sert d'intermédiaire obligatoire. L'unité SIMD est connectée aux registres SIMD, l'unité scalaire est reliée au banc de registres scalaire. Rien d'étonnant. L'unité SIMD peut aussi lire des scalaires pour certaines instructions SIMD verticales, ce qui fait qu'elle est aussi connecté au banc de registres scalaires. [[File:MIAOW GPU diagram.png|centre|vignette|upright=2|Microarchitecture d'un GPU, avec un focus sur un processeur de shader.]] ==Le ''multithreading'' matériel des processeurs de shaders== L'unité d'''issue'' détecte les dépendances de données, et bloque les instructions si elles ne doivent pas s'exécuter. L'inconvénient est que, quand une instruction est bloquée, les instructions suivantes sont aussi bloquées dans l'étage où elles sont. Rien ne progresse dans le pipeline tant que l'instruction fautive est bloquée. Heureusement, les GPU et les CPU disposent de techniques pour surmonter ce blocage, afin de continuer à exécuter des instructions. Les CPU disposent de techniques d'exécution dans le désordre, de renommage de registre, et bien d'autres. Mais leur implémentation demande un budget en transistor conséquent, que les GPU ne peuvent pas se permettre. A la place, ils utilisent une technique appelée le '''''multithreading'' matériel''', qui vient du monde des CPU. Vous connaissez sans doute l'''hyperthreading'' d'Intel ? C'est une version basique du ''multithreading'' matériel. L'idée est d'exécuter plusieurs programmes en même temps sur le même processeur, le processeur commutant de l'un à l'autre suivant les besoins. Par exemple, si un programme est bloqué par un accès mémoire, d'autres programmes exécutent des calculs dans l'unité de calcul en parallèle de l'accès mémoire. Pour un GPU, les programmes en question sont des instances de shader qui travaillent sur des données différentes. Ces instances de shader portent les noms de ''warp'' dans la terminologie NVIDIA, mais on peut aussi parler de ''threads'' pour utiliser la même terminologie que pour les CPUs. Un processeur de ''shader'' commute donc régulièrement d'un ''warp'' à l'autre, suivant les besoins. Dans ce qui va suivre, nous allons voir dans quelles situations un processeur de ''shader'' change de ''thread''/''warp'' en cours d'exécution. Suivant le GPU, les situations ne sont pas les mêmes. Il existe trois techniques de ''multithreading'' matériel : le ''Fine Grained Multithreading'', le ''Coarse Grained Multithreading'' et le ''Simultaneous MultiThreading''. Dans ce qui suit, nous utiliserons l'abréviation FGMT pour parler du ''Fine Grained Multithreading'', de CGMT pour parler du ''Coarse Grained Multithreading'' et de SMT pour le ''Simultaneous MultiThreading''. Les GPU ont d'abord implémenté le CGMT dans la période DirectX 9, puis sont passé au FGMT, avant de passer au SMT sur les générations récentes. Aussi, nous allons les voir dans l'ordre. ===Le ''Coarse Grained Multithreading'' de l'époque DirectX 9=== Les processeurs de shader sont connectés à une mémoire vidéo très lente, avec un temps d'accès élevé, qui se rattrape avec un débit binaire important. La conséquence est qu'un accès à une texture, c'est long : si celle-ci est lue depuis la mémoire vidéo, le temps d'attente est d'une bonne centaine de cycles d'horloges. Pour limiter la casse, les unités de texture incorporent un cache de texture, mais cela ne suffit pas toujours à alimenter les processeurs de shaders en données. Et ces derniers ne peuvent pas recourir à des techniques avancées communes sur les CPU, comme l’exécution dans le désordre : le cout en circuit serait trop important. [[File:Coarse Grained Multithreading.png|thumb|Coarse Grained Multithreading.]] Fort heureusement, les processeurs de shaders utilisent le ''multithreading'' matériel pour masquer la latence des accès mémoire. L'idée est que si un ''thread'' démarre un accès mémoire, il est mis en pause pendant l'accès mémoire, et laisse sa place à un autre ''thread''. Ainsi, pendant qu'un ''thread'' est bloqué par un accès mémoire, un autre ''thread'' utilise les unités de calcul en parallèle. Cela permet de masquer la latence des accès mémoire. : Notons qu'avec cette technique, les lectures mettent en pause le ''thread'' qui les exécute. On parle alors de '''lectures bloquantes'''. Nous verrons que les processeurs de shader plus récents exécutent des lectures non-bloquantes, mais ce sera pour la suite. La technique ne donne de bons résultats que si les accès mémoire sont peu fréquents, ou que le nombre de ''threads'' est élevé. Plus les accès mémoire sont fréquents, plus il faut un nombre de ''threads'' important pour masquer la latence. A l'époque, il était rare que les ''vertex shader'' accèdent à des textures, alors que les pixels shaders ne faisaient que ça. Ls processeurs de shaders de la Geforce 6 géraient au maximum 4 ''threads'' simultanés, alors que les processeurs de pixel shaders en géraient facilement plus d'une centaine. Les cartes concurrentes d'ATI supportaient 128 ''threads'' maximum, par processeur de pixel shader. L'implémentation de cette technique est assez simple, au premier abord. Déjà, il y a un ''Program Counter'' par ''thread''. À chaque cycle, un multiplexeur choisit le ''Program Counter'' - le thread - qui a la chance de charger ses instructions. Le choix du ''program counter'' sélectionné est le fait de l''''unité d'ordonnancement'''. L'unité d'ordonnancement sait quels ''threads'' sont en cours d'exécution et lesquels sont en pause. Pour cela, elle intègre une petite mémoire qui mémorise l'état de chaque ''thread''. [[File:Architecture d'un processeur multithreadé.png|centre|vignette|upright=2|Architecture d'un processeur multithreadé]] La technique impose cependant que les registres soient dupliqués, pour que chaque ''thread'' ait ses propres registres rien qu'à lui. Sans cela, impossible de passer rapidement d'un ''thread'' à l'autre à chaque cycle. Un processeur de ''shader'' peut exécuter entre 16 et 32 ''threads''/''warps'', ce qui multiplie le nombre de registres par 16/32. Et il y a la même chose avec d'autres structures matérielles, comme les files de lecture/écriture de l'unité d'accès mémoire. [[File:Aperçu de l'architecture d'un processeur multithreadé.png|centre|vignette|upright=2|Aperçu de l'architecture d'un processeur multithreadé]] : La technique générale porte le nom de ''Coarse Grained Multithreading''. C'est une forme de ''Multithreading'' où on change de programme quand un évènement bien précis a lieu. Les GPU en utilisaient une version où les évènements en question sont des accès mémoire. En théorie, la technique s'adapte pour d'autres opérations que les accès mémoire, tant que celles-ci prennent beaucoup de temps. Mais je ne saurais dire si les GPU l'appliquaient pour autre chose que les accès mémoire. ===Interlude propédeutique : le ''Fine Grained Multithreading''=== [[File:Fine Grained Multithreading.png|thumb|Fine Grained Multithreading]] Peu avant les années 2010, les processeurs de shaders ont subit quelques changements, afin d'augmenter leur performance. Notamment, le nombre d'étapes du pipeline a augmenté, histoire d’exécuter plus d'instructions simultanément. Et cela a commencé à poser quelques problèmes : les situations où deux instructions utilisent les mêmes registres a augmenté, les dépendances de données sont devenues un problème. Et pour résoudre ces problèmes, les GPU ont basculé du CGMT vers une forme de ''multithreading'' qu'on ne trouve que sur les GPU, ou presque. Et pour la comprendre, nous allons devoir faire un léger détour par une forme de ''multithreading'' très similaire, utilisée sur les CPU. Il s'agit du ''Fine Grained Multithreading'' (FGMT). Avec le FGMT, le processeur de shader change de ''thread'' à chaque cycle d'horloge. Le processeur fait donc une rotation, à chaque cycle, parmi les ''threads'' actifs. Les ''threads bloqués'' par un accès mémoire ne sont pas pris en compte. Par exemple, imaginons que le processeur gère 32 ''threads'' simultanés, mais que 8 d'entre eux soient en pause lors d'un accès mémoire. Dans ce cas, il changera de ''thread'' tous les 24 cycles, car il ne prend en compte que les ''threads'' non-bloqués. Faire ainsi a de nombreux avantages, notamment pour ce qui est des dépendances entre instructions. En changeant de ''thread'' à chaque cycle, on "espace" les instructions d'un même ''thread''. Par exemple, si on a 8 ''threads'' qui s'exécutent en même temps, alors il y a 8 cycles d'horloges entre deux instructions d'un même ''thread''. La première instruction a alors eu tout le temps pour enregistrer son résultat dans les registres, avant même que la seconde instruction lise ses opérandes. Le processeur peut alors utiliser un ''scoreboard'' très limité, très simple, pour détecter les rares dépendances de données qui restent, voire peut se passer complétement de ''scoreboard'' ! ===Le ''Multithreading'' commandé par le ''scoreboard'' des années 2005-2010=== [[File:Full multithreading.png|thumb|Full multithreading]] Sur les GPU récents, le processeur de shader ne change pas de ''thread'' à chaque cycle. Il préfère exécuter plusieurs instructions consécutives d'un même ''thread'', dans des cycles d'horloge consécutifs. Il ne change de ''thread'' que quand une dépendance de donnée bloque une instruction. C'est donc le ''scoreboard'' qui commande le changement de ''thread'', là où le changement de ''thread'' était réalisé sans lui avec le CGMT ou le FGMT. L'implémentation matérielle sépare le processeur de ''shader'' en deux sections séparées par une mémoire tampon qui met en attente les instructions. Les instructions sont chargées et décodées, puis sont placées dans une ou plusieurs '''files d'instruction'''. Le cas le plus simple à comprendre utilise une file d'instruction par ''thread''. A chaque cycle, l'unité d'émission vérifie plusieurs instructions, une par file d'instruction. Il choisit alors une instruction prête, puis l'envoie aux unités de calcul. L'instruction peut venir de n'importe quelle file d'instruction. Mais pour que cela fonctionne, il faut que les files d'instructions soient remplies, ce qui implique que le processeur doit avoir chargée des instructions en avance. Plus haut, nous avions dit que l'unité de chargement mémorise plusieurs ''program counter'', un par ''thread'', et choisit à chaque cycle l'un d'entre eux pour charger une instruction. Le choix en question est synchronisé avec l'émission des instructions. Si un ''thread'' émet une instruction, ce même ''thread'' charge une instruction au même moment. Sauf si la file d'instruction du ''thread'' est déjà pleine, auquel cas un autre ''thread'' est choisi. Précisons que le grand nombre de registres et de ''threads'' fait qu'un ''scoreboard'' classique, tel que décrit dans les cours d'architecture des ordinateurs, devient rapidement impraticable. Aussi, une implémentation alternative est utilisée, bien que les détails ne soient pas connus à ce jour. Quelques brevets donnent des détails d'implémentation, mais on ne sait pas s'ils sont à jour. Les curieux peuvent tenter de lire le brevet américain numéro #7,634,621, nommé ''"Register File Allocation"'', déposé par NVIDIA durant décembre 2009. [[File:FGMT sur un processeur de shaders.png|centre|vignette|upright=2|FGMT sur un processeur de shaders]] L'usage d'un ''scoreboard'' permet de nombreuses optimisations, qui portent notamment sur les lectures, qui interagissent avec le FGMT. L'optimisation en question s'appelle les '''lectures non-bloquantes'''. Elle fonctionne si la lecture est suivie par d'autres instructions qui n’accèdent pas à la mémoire, par exemple des instructions de calculs. L'idée est d'exécuter ces instructions en avance, pendant que la lecture récupère la donnée. C'est possible car la lecture utilise l'unité mémoire et le cache, mais laisse libre les unités de calcul et les registres. En clair, pendant qu'on lit une texture, on fait des calculs en parallèle dans les ALU. Mais la technique n'est possible que si les instructions de calcul n'utilisent pas la donnée en cours de lecture. Heureusement, le ''scoreboard'' détecte si cela arrive en surveillant les registres. La lecture charge une donnée dans un registre, appelé le registre de destination (sous entendu, de destination de la lecture). Les instructions qui n'utilisent pas ce registre peuvent s'exécuter sans problèmes : elles sont indépendantes de la lecture. Mais dès qu'une instruction souhaite lire ou écrire dans ce registre, elle est bloquée par le ''scoreboard'' et le processeur change de ''thread''. Détecter les dépendances demande juste de mémoriser le registre de destination dans un registre temporaire, et de regarder si l'instruction à exécuter utilise ce registre. Une différence avec le CGMT est le moment où un ''thread'' est bloqué par une lecture. Avec des lectures bloquantes, un ''thread'' est bloqué dès que la lecture est lancée. Et ce même si les instructions suivantes sont indépendantes de la lecture. Par contre, avec les lectures non-bloquantes, un ''thread'' n'est pas bloqué à ce moment-là. Il continue son exécution jusqu'à ce qu'une instruction accède à la donnée lue. Si la lecture s'est déjà terminée, alors la donnée est disponible et le ''thread'' continue de s'exécuter. Mais si la lecture est encore en cours, alors le processeur bloque l'instruction et change de ''thread''. Une autre optimisation possible est l'usage de l''''émission multiple'''. Avec elle, le ''scoreboard'' peut émettre deux instructions lors du même cycle d'horloge, si elles utilisent des unités de calcul différentes. Les deux instructions doivent faire partie du même ''thread'', mais certains GPU acceptent qu'elles soient de deux ''threads'' différents, tout dépend du GPU. Par exemple, il est possible qu'une instruction d'un ''thread'' utilise l'unité de calcul SIMD, alors que l'autre lance une lecture de texture. Ou encore, les deux peuvent lancer des calculs SIMD, mais dans deux unités SIMD séparées. ===L'encodage explicite des dépendances sur les GPU post-2010=== Depuis environ 2010, les GPU n'utilisent plus de ''scoreboard'' proprement dit. A la place, les GPU modernes déportent la détection des dépendances de données à la compilation. L'idée est que chaque instruction contient quelques bits pour dire au processeur : tu peux lancer 1, 2, 3 instructions à la suite sans problème. La technique porte le nom d''''anticipation de dépendances explicite''' (''Explicit-Dependence lookahead''). Un exemple historique assez ancien est le processeur Tera MTA (''MultiThreaded Architecture''), qui utilisait cette technique. Les GPU NVIDIA modernes utilisent plusieurs bits par instruction pour gérer les dépendances de données. Un premier mécanisme est utilisé pour bloquer l'émission d'une nouvelle instruction pendant x cycles. Il utilise un '''''stall counter''''', qui mémorise le nombre de cycles d'attente. Une instruction peut initialiser le ''stall counter'' avec une valeur de base, qui indique combien de cycles attendre. Le ''stall counter'' est décrémenté à chaque cycle d'horloge et une nouvelle instruction s'exécute seulement quand le compteur atteint 0. Il est utilisé quand une instruction productrice prenant X cycles est suivie par une instruction consommatrice, le ''stall counter'' est alors initialisé à la valeur X. La méthode précédente ne fonctionne que pour les instructions dont le compilateur peut prédire la durée. Les accès mémoire et certains calculs complexes ne sont pas dans ce cas. Pour les gérer, les GPU modernes utilisent un autre mécanisme, basé sur des '''compteurs de dépendances'''. Il y en a entre 5 et 10 selon le GPU. Une instruction dite productrice réserve un de ces compteurs, l'incrémente quand elle démarre son exécution/est émise, le décrémente quand la dépendance est résolue. Les instructions consommatrices, qui utilisent le résultat de l'instruction productrice, attendent que le compteur tombe à zéro pour s'exécuter. Elles précisent quels compteurs regarder avec un '''masque de compteurs de dépendance''', encodé directement dans l'instruction elle-même. Précisément, chaque instruction productrice se réserve deux compteurs, pour gérer les trois types de dépendances de données (RAW, WAR et WAW). Le premier compteur est décrémenté quand le résultat est écrit dans les registres, ce qui gère naturellement les dépendances RAW et WAW. Le second est décrémenté quand l'instruction a lu ses opérandes, ce qui gère les dépendances WAR. Les autres instructions regardent l'un ou l'autre des compteurs selon leur situation. Il arrive que le switch de ''thread'' soit déclenché par des bits intégrés dans l'instruction. Par exemple, sur les GPU NVIDIA modernes, chaque instruction contient un bit '''''yield''''' qui indique qu'il faut changer de ''thread'' une fois l'instruction émise. En clair, il indique que l'instruction risque de durer longtemps, a des dépendances avec la mémoire ou autre chose qui fait qu'il est préférable de changer de ''thread''. ==Le banc de registres d'un processeur de ''shader''== Les GPU disposent d'un grand nombre de registres. Les normes de DirectX et Open GL imposent que les shaders modernes gèrent au moins 4096 registres généraux par instance de shader, avec des registres spécialisés en plus. En soi, 4096 est énorme ! Mais au-delà de ces normes, le FGMT implique de dupliquer des registres par le nombre de ''threads hardware'' simultanés. Avec le FGMT, les registres devront être dupliqués pour que chaque ''thread'' ait ses propres registres rien qu'à lui. Sans cela, impossible de passer rapidement d'un ''thread'' à l'autre à chaque cycle. Maintenant, faisons quelques calculs d'épiciers. Un processeur de ''shader'' peut exécuter entre 16 et 32 ''threads''/''warps'', ce qui multiplie le nombre de registres par 16/32. En multipliant par les 4096 registres nécessaires, cela fait 128 kilooctets de mémoire rien que pour les registres. Et c'est pour un seul cœur ! Si on multiplie par le nombre de cœurs, on trouve que les cartes graphiques modernes ayant plusieurs processeurs de ''shaders'' ont facilement entre 32 768 et 65 536 registres de 32 bits, ce qui est énorme ! Il y a plus de mémoire gaspillée dans les registres que dans le cache L1 ou L2 ! Et ce grand nombre de registres par cœur pose quelques problèmes. Les registres sont regroupés dans une petite mémoire SRAM, adressable, appelée le '''banc de registre'''. Et comme toutes les mémoires, plus ce banc de registres est grand, plus il est lent. En conséquence, lire un opérande dans les registres prend beaucoup de temps. Du moins, c'est le cas sans optimisations. Et les GPU implémentent de nombreuses parades pour limiter le nombre de registres réellement présents dans leur silicium. ===L'allocation dynamique/statique des registres par ''thread''=== Les GPU modernes n'implémentent pas le nombre maximal de registres demandés. Par exemple, si je prends les GPU AMD de type RDNA 4, il peuvent gérer 16 ''threads'' hardware simultanés, chacun ayant accès à 256 registres, registres faisant 128 octets chacun. On s'attend à avoir un banc de registre de 16 * 256 * 128 octets, soit 512 Kilo-octets. En somme, un banc de registre de la taille du cache L1. Sauf que les GPUs en question intègrent moins de registres que prévu ! Ils ont précisément une taille de 192 kilo-octets, soit 96 registres pour chacun des 16 ''threads''. En effet, 256 registres est un nombre maximal, que la plupart des ''shaders'' n'utilise pas totalement. La plupart des ''shaders'' utilise entre 64 et 128 registres, rarement moins, rarement plus. Aussi, le GPU partitionne le banc de registres à la demande entre les ''threads'', en leur donnant seulement une partie des registres. Le partitionnement peut être pseudo-statique, à savoir que le banc de registre est découpé en parts égales pour chaque ''thread'', ou dynamique avec un nombre de registre variant d'un ''thread'' à l'autre, selon les besoins. Prenons l'exemple d'un '''partitionnement pseudo-statique''', avec l'exemple des GPU AMD RDNA 1 et 2. Leur banc de registre fait 1024 registres, de 128 octets chacun, soit 128 KB au total. Le GPU gère 16 ''threads'' simultanés maximum. Avec un seul ''thread'' d'exécuté, le ''thread'' unique peut utiliser les 1024 registres du banc de registre pour lui tout seul. Avec deux ''threads'', chacun aura droit à 512 registres, soit la moitié du cas précédent. Avec 16 ''thread'' simultanés, chaque ''thread'' a accès à 64 registres, pas plus. Et ainsi de suite : le nombre de registre par ''thread'' est égal à la taille du banc de registre divisée par le nombre de ''threads''. A l'heure où j'écris ces lignes, courant 2025, les GPU Intel se contentent d'un partitionnement statique très limité. Avant les GPU d'architecture Battlemage, il n'y avait pas de partitionnement du banc de registre, tous les ''threads'' avaient 128 registre à leur disposition. Les GPU Battlemage et ses successeurs ont introduit un partitionnement limité, avec deux modes : un mode sans partitionnement où tous les ''threads'' ont accès à 128 registres, un mode avec partitionnement qui divise le nombre de ''thread'' par deux et leur donne chacun 256 registres. Pas de possibilité de diviser plus le nombre de ''threads''. Les GPU AMD et NVIDIA sont eux plus compétents niveau partitionnement statique. Par exemple, les GPU RDNA 4 supportent les partitionnements suivants : * 16 ''threads'' avec 96 registres chacun ; * 12 ''threads'' avec 120 registres chacun ; * 10 ''threads'' avec 144 registres chacun ; * 9 ''threads'' avec 168 registres chacun ; * 8 ''threads'' avec 192 registres chacun ; * 7 ''threads'' avec 216 registres chacun ; * 6 ''threads'' avec 240 registres chacun ; * 5 ''threads'' avec 256 registres chacun. Le partitionnement pseudo-statique est simple à implémenter, il ne demande pas beaucoup de circuits pour fonctionner. Il est rapide et a de bonnes performances pour le rendu graphique en rastérisation. La raison est qu'en rastérisation, les différents ''threads'' sont souvent des copies/instances d'un même ''shader'' qui travaillent sur des données différentes. Leur donner le même nombre de registres colle bien avec cet état de fait. Cependant, si les différents ''threads'' sont des ''shaders'' différents, les choses ne sont pas optimales. Un ''shader'' utilisera plus de registres que l'autre, leur donner le même nombre de registres n'est pas optimal. Par exemple, imaginons que l'on a deux shaders, nommés shaders 1 et 2, aux besoins différents, l'un étant gourmand en registres et l'autre très économe. Dans ce cas, il faudrait partitionner le banc de registre pour donner plus de registre au premier et moins au second. Il s'agit là d'un '''partitionnement dynamique'''. Le partitionnement dynamique est plus optimal pour gérer des ''shaders'' déséquilibrés niveau registres, mais a une implémentation matérielle plus complexe. Il a été introduit assez tard, car il a fallu attendre que le rayctracing se démocratise. En effet, le partitionnement dynamique du banc de registre est surtout utile pour le raytracing. Exécuter simultanément des shaders déséquilibrés en registres est peu fréquent avec la rastérisation, beaucoup plus courant avec le raytracing. NVIDIA et AMD ont des implémentations différentes du partitionnement dynamique. Les GPU AMD RDNA 4 allouent un nombre minimal de registre à chaque ''thread'', mais ils peuvent demander d'avoir accès à plus de registres si besoin. Quand un ''thread'' a besoin de plus de registres, il exécute une instruction dédiée, qui sert à demander plus de registres, ou au contraire à en libérer s'ils sont inutilisés. La demande d'allocation de nouveaux registres se fait par blocs de 16 à 32 registres, suivant comment est configuré le processeur. Précisons que l'instruction d'allocation n'est disponible que pour les ''compute shaders'' et pas les ''shaders'' graphiques. L'instruction d'allocation de registre précédent peut échouer dans certains cas. Si assez de registres sont disponibles, à savoir inutilisés par d'autres ''threads'', l'instruction réussit. Dans le cas contraire, elle échoue et le ''shader'' est mis en pause avant de retenter cette demande plus tard. Le résultat de l'instruction, échec ou réussite, est mémorisé dans le registre d'état. La technique a pour défaut que certaines situations peuvent mener à un blocage complet du processeur, où chaque ''thread'' ne peut plus poursuivre son exécution, faute de registres disponibles. Des méthodes pour éviter cette situation sont implémentés sur ces GPU, mais la documentation n'est pas très explicite. Sur les GPU NVIDIA, il y a aussi une instruction d'allocation de registre, mais elle fonctionne différemment. Elle permet d'échanger des registres entre ''threads''. Une première différence est que tous les ''threads'' commencent avec une allocation égale des registres. Les ''threads'' démarrent tous avec le même nombre de registres. Un ''thread'' peut libérer des registres, qui sont alors alloués à un autre ''thread'', le ''thread'' en question pouvant être choisit par le ''thread'' qui libère les registres. ===Le banc de registre est multiport de type externe=== Le banc de registre doit permettre de lire deux vecteurs SIMD par opération, soit deux lectures simultanées. Pour cela, le banc de registre contient deux ports de lecture, chacun permettant de lire un opérande dans le banc de registre. Mais plus le nombre de ports augmente, plus la consommation énergétique du banc de registre augmente, sans compter que celui-ci devient plus lent. Les processeurs peuvent utilisent des bancs de registres ayant réellement deux ports par banc de registre. Un port de lecture est implémenté avec un composant appelé un multiplexeur, connecté à tous les registres. [[File:Mémoire multiport faite avec des MUX-DEMUX.png|centre|vignette|upright=2|Mémoire multiport faite avec des MUX-DEMUX]] Les GPU ne peuvent pas se permettre un tel luxe. Leur banc de registre doit alimenter plusieurs unités de calcul en même temps, en parallèle. Le nombre de ports serait plus proche de 4 à 10 ports de lectures. La solution précédente aurait un budget en transistor et un budget thermique trop important. À la place, ils utilisent une autre méthode : ils simulent un banc de registre à plusieurs ports à partir de plusieurs bancs de registres à un port. Le banc de registre est en réalité formé de plusieurs banques, de plusieurs bancs de registre séparés, chacun mono-port. L'idée est que si l'on accède à deux banques en même temps, on peut lire deux opérandes, une par port/banc de registre. Par contre, si les deux opérandes à lire sont la même banque, il y a un '''conflit d'accès aux banques'''. [[File:Mémoire à multiports par banques.png|centre|vignette|upright=2|Mémoire à multiports par banques.]] ===L'''Operand Collector'' et les caches de ''register reuse''=== Sans conflit d'accès à une banque, les deux opérandes sont disponibles immédiatement. Par contre, en cas de conflit d'accès aux banques, les deux opérandes sont lus l'une après l'autre. En clair, le premier opérande doit être mis en attente quelque part pendant que la seconde est en cours de lecture. Et le problème survient souvent, surtout avec les opérations FMA qui utilisent trois opérandes, encore plus avec les rares opérations qui demandent 4 à 5 opérandes. Pour gérer les conflits d'accès aux banques, les GPU utilise un circuit dédié, appelé le '''collecteur d'opérandes''' (''operand collector''). Le rôle du collecteur d'opérandes est d'accumuler les opérandes en attente, son nom est assez transparent. Il accumule les opérandes en attente, puis les envoie aux unités de calcul quand elles sont toutes prêtes, toutes lues depuis le banc de registres. Les opérandes sont mis en attente dans des entrées, qui contiennent la donnée, l'identifiant du ''thread''/''warp'' pour ne pas confondre des opérandes entre ''threads'', et deux bits d'occupation. Les deux bits d'occupation indiquent si l'entrée est vide, réservée pour un opérande en cours de lecture ou occupée par un opérande. Le collecteur d'opérande est souvent accompagné de '''registres temporaires''', qui mémorisent le résultat d'une instruction précédente. Il y a un registre temporaire par ALU, le résultat fournit par une ALU est mémorisé dans le registre temporaire associé. Une instruction peut lire une opérande dans un registre temporaire, ce qui permet de lire le résultat d'une instruction précédente sans passer par le banc de registres. Le collecteur d'opérande est alors configuré pour récupérer les opérandes adéquats dans les registres temporaires adéquats. : Pour faire une comparaison avec les processeurs modernes, ces registres sont une forme de ''data forwarding'', de contournement, mais qui est rendue explicite pour le logiciel. Une optimisation des GPU récent vise à réduire les accès aux bancs de registres en utilisant une mémoire cache spécialisée. Il s'agit de l'''operand reuse cache'', aussi appelés '''''register reuse cache'''''. L'idée est que quand un opérande est lu depuis le banc de registres, elle peut être stockée dans ce cache pour des utilisations ultérieures. Les architectures Volta, Pascal et Maxwell disposent de 4 caches de ce type, chacun stockant 8 données/opérandes/résultats Il s'agit en réalité de pseudo-caches, car ils sont partiellement commandés par le logiciel. Une instruction précise qu'un opérande doit être stocké dans un ''register reuse cache'', pour une utilisation ultérieure. Pour cela, elle incorpore quelques bits pour préciser qu'elle doit être placée dans le cache. Les bits font en quelque sorte partie du mode d'adressage. Si l'instruction immédiatement suivante lit ce registre dans le même ''slot'' d'opérande lira l'opérande dans le cache. La technique est donc assez limitée, mais elle a des résultats pas négligeables. Les ''register reuse cache'' et le collecteur opérandes sont sans doute fusionnés en un seul circuit, plutôt que d'utiliser deux circuits séparés. La raison est que les deux doivent mémoriser des opérandes les mettre en attente pour une utilisation ultérieure, et sont placés juste après le banc de registre. NVIDIA a publié deux brevets à propos de ces deux techniques, mais rien n'indique que c'est exactement cette technique qui utilisée dans les cartes modernes. Il faut dire que le nombre de banques a changé suivant les cartes graphiques. * [https://patents.google.com/patent/US7834881B2/en Operand collector architecture ] * [https://patents.google.com/patent/US20130159628A1/en Methods and apparatus for source operand collector caching ] <noinclude> {{NavChapitre | book=Les cartes graphiques | prev=Les processeurs de shaders | prevText=Les processeurs de shaders | next=Les caches d'un processeur de shader | netxText=Les caches d'un processeur de shader }}{{autocat}} </noinclude> rw968tsfiwad6fivqiw8qjcjppnvkm5 765168 765156 2026-04-26T20:35:15Z Mewtow 31375 /* L'Operand Collector et les caches de register reuse */ 765168 wikitext text/x-wiki La conception interne (aussi appelée microarchitecture) des processeurs de ''shaders'' possède quelques idiosyncrasies. Mais avant d'expliquer lesquelles, nous devons prévenir d'une chose importante : dans ce chapitre, nous ne parlerons que des GPU de l'époque DirectX 10 et après, pas des GPU de l'époque DirectX 9. La raison est que leur jeu d'instruction a franchement évolué, avec le passage d'architectures VLIW à des architectures SIMD. Et cela a eu des conséquences assez profondes sur le jeu d'instruction et leur microarchitecture. Nous n'allons parler des GPU de type SIMD dans ce chapitre. Un chapitre dédié sera consacré aux GPU de type VLIW. Pour rappel, un processeur de shader supporte plusieurs types d'instructions. Au minimum, il supporte des ''instructions SIMD''. Mais il peut aussi gérer des ''instructions scalaires'', à savoir qu'elles travaillent sur des entiers/flottants isolés, en dehors d'un vecteur SIMD. Typiquement, il y a plusieurs types d'instructions scalaires : les calculs entiers, les calculs flottants simples, les calculs flottants complexes dit transcendantaux (calculs trigonométriques, des exponentielles, des logarithmes, des racines carrées ou racines carrées inverse). En clair, un processeur de shader doit savoir faire des calculs. Et il a des circuits dédiés pour ça, appelés des unités de calcul. Les unités de calcul sont souvent classées en deux types : les ALU et les FPU. Les premières font des calculs sur des opérandes entiers, alors que les secondes font des calculs flottants. Au passage, le terme ALU signifie ''Arithmetic and Logic Unit'', alors que FPU signifie ''Floating Point Unit'', les deux termes étant assez parlants. Mais un GPU utilise non seulement des ALU et des FPU, mais aussi des regroupements de plusieurs ALU/FPU. Voyons cela en détail. ==Les unités de calcul d'un processeur de shader SIMD== Un processeur de shader contient un grand nombre d'unités de calcul très différentes. Le cœur est une unité de calcul SIMD, qui se charge des instructions SIMD. A cela, il faut souvent rajouter une ALU ou une FPU, parfois des unités de calcul flottantes spécialisées pour les opérations complexes, comme des opérations trigonométriques ou transcendantales. ===Les unités de calcul SIMD=== [[File:SIMD2.svg|vignette|Une unité de calcul SIMD.]] Un processeur de shader incorpore une unité de calcul SIMD, qui effectue plusieurs calculs en parallèle. Elle regroupe plusieurs ALU flottantes regroupées ensemble et avec quelques circuits pour gérer les débordements et d'autres situations. En théorie, une unité de calcul SIMD regroupe autant d'unité de calcul qu'il y a d'entiers/flottants dans un vecteur. Par exemple, pour additionner deux vecteurs contenant chacun 16 flottants, il faut utilise 16 additionneurs flottants. Ce qui fait qu'une opération sur un vecteur est traité en une seule fois, en un cycle d'horloge. Une contrainte importante est que toutes les sous-ALU effectuent la même opération : ce ne sont pas des ALU séparées qu'on peut commander indépendamment, mais une seule ALU regroupant des circuits de calcul distinct. Le cout en circuit est d'autant plus grand que les vecteurs sont longs et le cout est approximativement proportionnel à la taille des vecteurs. Entre des vecteurs de 128 et 256 bits, l'unité de calcul utilisera globalement deux fois plus de circuits avec 256 bits qu'avec 128. Même chose pour les registres, mais c'est là un cout commun à toutes les architectures. Il y a quelques unités de calcul SIMD où le calcul se fait en deux fois, car on n'a que la moitié des unités de calcul. Par exemple, pour un vecteur de 32 flottants, on peut utiliser 16 unités de calcul, mais le temps de calcul se fait en deux cycles d'horloge. Les opérations sur les vecteurs sont donc faites en deux fois : une première passe pour les 16 premiers éléments, une seconde passe pour les 16 restants. L'implémentation demande cependant qu'une instruction de calcul soit décodée en deux micro-opérations. Par exemple, une instruction SIMD sur des vecteurs de 32 éléments est exécutée par deux micro-instructions travaillant sur des vecteurs de 16 éléments. On économise ainsi pas mal de circuits, mais cela se fait au détriment de la performance globale. L'avantage est que cela se marie bien avec l'abandon des opérations pour lesquelles masques dont tous les bits sont à 0. Par exemple, prenons une instruction travaillant sur 16 flottants, exécutée en deux fois sur 8 flottants. Si le masque dit que les 8 premières opérations ne sont pas à exécuter, alors l'ALU ne fera que le calcul des 8 derniers flottants. Pour cela, le décodeur doit lire le registre de masque lors du décodage pour éliminer une micro-instruction si besoin, voire les deux si le masque est coopératif. ===Plusieurs unités SIMD, liées au format des données=== Il faut préciser qu'il y a une séparation entre unités SIMD flottantes simple et double précision. Pour le dire plus clairement, il y a des unités SIMD pour les flottants 32 bits, d'autres pour les flottants 64 bits, et même d'autres pour les flottants 16 bits. Les flottants 64 bits sont utilisés dans les applications GPGPU, les flottants 16 et 32 bits le sont dans le rendu 3D, et les flottants 16 bits pour tout ce qui lié à l'IA. Malheureusement, on doit utiliser des ALU flottantes séparées pour chaque taille, le format des flottants n'aidant pas. Depuis plus d'une décennie, les cartes graphiques ont des unités SIMD à la fois pour les calculs entiers et flottants. Elles sont censées être séparées. Pour NVIDIA, avant l'architecture Turing, les unités SIMD entières et flottantes sont décrites comme séparées dans leurs ''white papers'', avec des unités INT32 et FLOAT32 séparées, combinées à d'autres unités de calcul. L'architecture Volta a notamment des unités INT32, FLOAT32 et FLOAT64 séparées. A partir de l'architecture Ampere, il semblerait que les unités SIMD soient devenues capables de faire à la fois des calculs flottants et entiers, pour la moitié d'entre elles. Mais il se pourrait simplement qu'elles soient physiquement séparées, mais reliées aux reste du processeur de manière à ne pas être utilisables en même temps. Il est cependant possible que sur d'anciennes architectures, les unités entières et flottantes partagent des circuits, notamment pour ce qui est de la multiplication. En effet, une unité de calcul flottante contient des circuits pour faire des calculs entiers pour additionner/multiplier les mantisses et les exposants. Il est possible d'utiliser ce circuit Un exemple est le cas de l'architecture GT200 d'NVIDIA, sur laquelle les "pseudo-ALU" entières SIMD étaient limitées à des multiplications d'opérandes 24 bits, ce qui correspond à la taille d'une mantisse d'un flottant 32 bits. Le design exact des ALU n'est pas connu. ===Les unités de calcul scalaires=== Les GPU modernes incorporent une '''unité de calcul entière scalaire''', séparée de l'unité de calcul SIMD. Elle gère des calculs scalaires, à savoir qu'elle ne travaille pas sur des vecteurs. Elle gère divers calculs, comme des additions, soustractions, comparaisons, opérations bit à bit, etc. Elle exécute les instructions de calcul entière sur des nombres entiers isolés, de plus en plus fréquentes dans les shaders. Elle est parfois accompagnée d'une unité de calcul pour les branchements. Par branchements, on veut parler des vrais branchements similaires à ceux des CPU, qui effectuent des tests sur des entiers et effectuent des branchements conditionnels. Ils n'ont rien à voir avec les instructions à prédicat qui elles sont spécifiques à l'unité de calcul vectorielles. Ce sont des instructions séparées, totalement distinctes Les processeurs de shaders incorporent aussi une '''unité de calcul flottante scalaire''', utilisée pour faire des calculs sur des flottants isolés. L'unité de calcul gère généralement des calculs simples, comme les additions, soustractions, multiplications et divisions. Il s'agit typiquement d'une unité de calcul spécialisée dans l’opération ''Multiply-And-Add'' (une multiplication suivie d'une addition, opération très courante en 3D, notamment dans le calcul de produits scalaires), qui ne gère pas la division. L'unité de calcul flottante est souvent accompagnée d'une unité de calcul spécialisée qui gère les calculs transcendantaux, avec une gestion des calculs trigonométriques, de produits scalaires ou d'autres opérations. Elle porte le nom d''''unité de calcul spéciale''' (''Special Function Unit''), ou encore d'unité de calcul transcendantale, et elle a d'autres appellations. Elle est composée de circuits de calculs accompagnés par une table contenant des constantes nécessaires pour faire les calculs. Rappelons que les registres SIMD et les registres scalaires sont séparés et ne sont pas adressés par les mêmes instructions. Les registres scalaires sont placés dans un banc de registre physiquement séparé du banc de registres SIMD. Le banc de registre scalaire est relié à sa propre ALU scalaire, il y a vraiment une séparation physique entre registres scalaires et SIMD. Il existe cependant un système d'interconnexion qui permet d'envoyer un scalaire aux unités SIMD, ce qui est utile pour les opérations de produits scalaire ou autres. ==L'intérieur d'un processeur de shader== : Cette section sera surtout des rappels, pour ceux qui ont déjà lu un cours d'architecture des ordinateurs, et connait la notion de pipeline. En plus des unités de calcul, un processeur contient d'autres circuits, et un processeur de shader ne fait pas exception. Un processeur est composé de quatre circuits principaux : * les unités de calcul, qui font des calculs et d'autres opérations ; * les registres pour mémoriser les opérandes des calculs et leurs résultats ; * une unité mémoire pour échanger des données entre VRAM et registres ; * une unité de contrôle qui exécute les instructions. Les unités de calcul, les registres et l'unité mémoire sont souvent regroupés sous le terme de '''chemin de données'''. Le terme dit clairement que c'est la partie du processeur qui gère les données, les manipule, fait des calculs, etc. A côté du chemin de données, il y a une unité de contrôle, qui commande ce chemin de données pour qu'il fasse les instructions demandées. L'unité de contrôle lit les instructions depuis la mémoire vidéo, et configure le chemin de données, pour qu'il exécute l'instruction demandée. L'unité de contrôle sera détaillée dans la section suivante. ===Le chemin de données d'un processeur de shader=== Un processeur de shader contient au minimum une unité SIMD, et une unité scalaire. Il est possible d'utiliser les deux en même temps, grâce aux instructions à ''co-issue''. Elles sont surtout utiles pour exécuter des instructions scalaires en parallèle d'instruction SIMD, mais guère plus. Notez que j'ai dit : "au minimum une unité SIMD et une unité scalaire", car les processeurs de shaders modernes dupliquent les unités de calcul, pour des raisons qu'on expliquera dans la suite du chapitre. Il n'est pas rare qu'un processeur de shader dispose d'une dizaine d'unités SIMD et de 2 ou 3 unités scalaires. Nous avons vu les registres dans le chapitre précédent, aussi je ne vais pas revenir dessus. Je vais juste préciser que les registres sont regroupés dans des '''bancs de registres'''. Ce sont de petites mémoires dont chaque adresse contient un registre. En général, il y a un banc de registre pour les scalaires et un autre pour les vecteurs SIMD. Les deux sont séparés car ils ne sont pas utilisés par les mêmes instructions. Et c'est plus pratique à implémenter. L''''unité d'accès mémoire''' s'occupe des lectures et écriture en général, et elle prend en charge les accès aux textures, le filtrage de textures et tout un tas de fonctionnalités liées aux textures. La seule différence entre un accès aux textures et une lecture/écriture en mémoire est que les circuits de filtrage de texture sont contournés dans une lecture/écriture normale. Dans ce qui suit, nous allons l'appeler l'unité de texture par souci de simplification. ===L'unité de contrôle d'un processeur de shader=== L'unité de contrôle d'un GPU a quelques petites différences avec celles d'un CPU moderne. Les unités de contrôle des GPU n'utilisent pas les optimisations des CPU modernes, tant utiles pour du calcul séquentiel : pas d’exécution dans le désordre, de renommage de registres, et autres techniques avancées. A la place, elles utilisent des techniques alternatives qu'on décrira pdans la suite du chapitre, qui sont peu gourmandes en transistors. En conséquence, les unités de contrôle sont très simples, prennent peu de place, utilisent peu de transistors. La majeure partie du processeur est dédié aux unités de calcul et aux registres. [[File:Cpu-gpu.svg|centre|vignette|upright=2.0|Comparaison entre l'architecture d'un processeur généraliste et d'un processeur de shaders.]] Un processeur de shader a une unité de contrôle assez classique, composée de plusieurs circuits. * une unité de ''Fetch'' qui calcule l'adresse de la prochaine instruction ; * un cache d'instruction dans lequel on récupère la prochaine instruction, en présentant son adresse ; * une unité de décodage d'instruction, qui traduit l'instruction en signaux de commande à destination de l'unité de calcul et des registres ; * une unité d’''issue'', aussi appelée le ''scoreboard'', qu'on détaillera dans ce qui suit. Le tout est illustré ci-dessous, avec le chemin de données. Vous remarquerez que dans le chemin de données, il y a aussi une unité pour enregistres les résultats dans les registres, qui effectue pas mal de traitements importants qu'on ne peut pas détailler ici. Vous remarquerez aussi que l'unité qui calcule l'adresse de la prochaine instruction est un peu complexe. Mais laissons cela de côté pour le moment. [[File:Vortex microarchitecture.png|centre|vignette|upright=2.5|Exemple de microarchitecture d'un processeur de shader.]] ===Le pipeline d'un processeur de shader=== Un point important est que les processeurs de shaders utilisent la technique du '''pipeline'''. Les unités vues précédemment fonctionnent indépendamment des autres. Elles n'attendent pas que l'instruction soit terminée pour commencer à traiter la suivante. Concrètement, la première unité traite l'instruction courante, pendant que la suivante traite l'instruction précédente, et ainsi de suite : * la première unité calcule l'adresse de l'instruction numéro N; * le cache d'instruction lit l'instruction numéro N-1 ; * l'unité de décodage décode l'instruction numéro N-2 ; * le ''scoreboard'' analyse l'instruction numéro N-3 ; * les unités de calcul exécutent l'instruction numéro N-4 ; * l'unité d'enregistrement écrit le résultat de l'instruction numéro N-5 dans les registres. Faire ainsi maximise les performances, car cela permet d'exécuter plusieurs instructions en même temps, à des étapes différentes. Le débit d'instruction est maximisé, les shaders s'exécutent plus vite. Le nombre d'étapes exact dépend du processeur. Il arrive que des processeurs fusionnent des étapes en un seul circuit. Par exemple, l'unité de décodage et le ''scoreboard'' peuvent être regroupés dans une seule étape, de même que l'accès aux registres et le calcul dans l'ALU. D'autres processeurs scindent certaines étapes en plusieurs sous-circuits séparés. Par exemple, il peuvent scinder leurs ALU en plusieurs sous-ALU, chacune exécutant un morceau de calcul. Et cela permet d'expliquer pourquoi un processeur de ''shaders'' SIMD contient beaucoup d'unités de calcul, identiques, ou non. Les instructions prennent plusieurs cycles d'horloge à s’exécuter, ce qui fait qu'une instruction occupe une unité de calcul pendant 2 à 20 cycles, rarement plus. Cependant, le processeur peut démarrer une nouvelle instruction par cycle d'horloge. Et cela permet malgré tout de démarrer une nouvelle instruction dans une unité de calcul libre. Pendant qu'une instruction en est à son second ou troisième cycle dans une ALU, il est possible de démarrer une nouvelle instruction dans une ALU inoccupée, sans voir recours à la ''co-issue''. s'il y en a une. Par exemple, reprenons l'exemple de l'unité de ''vertex shader'' de la Geforce 6800, mentionné au-dessus. Elle dispose d'une unité de calcul SIMD MAD, et d'une unité de texture, ainsi que d'une unité de calcul scalaire transcendantale. Il en en théorie possible de faire en même temps : un calcul dans l'ALU SIMD, une lecture de texture dans l'unité mémoire, un calcul trigonométrique dans l'unité transcendantale. Il suffit de lancer la lecture de texture à un cycle, l’instruction MAD au cycle suivant, et l'instruction spéciale deux cycles après. ===Le ''scoreboard'' d'un processeur de shader=== Exécuter plusieurs instructions en même temps pose un problème quand deux instructions consécutives sont dépendantes l'une de l'autre. Un cas classique est quand une instruction a besoin du résultat de la précédente. Dans ce cas, l'instruction accède aux registres alors que la première n'a pas encore écrit dedans, ce qui pose un problème. Il s'agit d'une dépendance dite RAW (''Read After Write'') typique, que la carte graphique doit gérer automatiquement. La seconde instruction ne doit pas démarrer tant que la précédente n'a pas enregistré son résultat dans les registres. Le ''scoreboard'' gère ce genre de problèmes. Il détecte les dépendances entre instructions et les gère sans intervention extérieure. Pour cela, il vérifie si les opérandes de l'instruction sont en cours de calcul. Pour cela, il regarde quels registres l'instruction lit/écrit, et vérifie s'ils sont en cours d'utilisation. Pour savoir quels registres sont en cours d'utilisation, rien de plus simple : quand il démarre une instruction, le ''scoreboard'' marque ses registres comme en cours d'utilisation. Le ''scoreboard'' se contente de bloquer l'instruction tant qu'elle ne peut pas s'exécuter. Et les instructions suivantes sont aussi bloquées dans l'étage où elles sont. Rien ne progresse dans le pipeline tant que l'instruction fautive est bloquée. Cependant, les processeurs de shaders disposent de plusieurs optimisations concernant ce genre de situations. Voyons cela en détail. Et au-delà de ça, le ''scoreboard'' bloque l'exécution d'une instruction si les conditions ne sont pas remplies. Notamment, il vérifie qu'il y a une unité de calcul libre pour exécuter l'instruction. Si ce n'est pas le cas, l'instruction est bloquée. Au-delà de ça, il existe d'autres dépendances liées au fait que deux instructions utilisent les mêmes registres. Mais avec 4096 registres par ''shader'', elles sont plus rares, ce qui fait qu'on les laisse volontairement de côté. ===Exemple et résumé final=== Pour finir, nous allons voir un exemple final, celui des GPU Radeon X1000 series, de microarchitecture R500, Terascale. Leur microarchitecture est résumée dans le schéma ci-dessous. La portion gauche du schéma montre plusieurs choses. Le GPU contient un processeur de commande, appelé l'''ultra threaded dispatcher'' sur ces GPU. Il alimente plusieurs processeurs de shaders, ici appelés des ''compute unit'' (CU). De nombreux circuits sont partagés entre plusieurs processeurs. Par exemple, ils partagent un cache L2, dans lequel ils viennent récupérer les données nécessaires. Le GPU contient aussi des contrôleurs mémoire, qui lisent ou écrivent des données en mémoire vidéo. Les contrôleurs mémoire servent surtout d'interface entre la mémoire vidéo et le cache L2, mais ils peuvent aussi envoyer des données lue aux processeurs de shaders. Le focus de droite montre ce qu'il y a dans un processeur de shader. Déjà, l'unité de contrôle est en haut et est nommée ''Fetch, Decode, Schedule'' : ''Schedule'' est un synonyme de ''Issue''. L'unité mémoire est appelée la ''Load Store Unit'' (LSU), elle communique avec la mémoire vidéo et les registres. A côté, on trouve une unité de calcul SIMD et une unité de calculs scalaire. Pour ce qui est des mémoires, elle montre qu'il y a une petite mémoire locale généraliste, complétée avec deux bancs de registres : un pour les données scalaires, un pour les vecteurs SIMD. * Les registres pour les scalaires sont appelés les ''Scalar General Purpose Registers'' (GPR). * Les registres pour les vecteurs SIMD sont appelés les ''Vector General Purpose Registers'' (VGPR). * La mémoire locale généraliste, appelée la mémoire partagée (LDS). Niveau interconnexions, les flèches montrent plusieurs choses. Premièrement, l'unité mémoire est reliée aux bancs de registres, ainsi qu'à la mémoire locale. Les accès à la mémoire locale passent par l'unité mémoire, qui sert d'intermédiaire obligatoire. L'unité SIMD est connectée aux registres SIMD, l'unité scalaire est reliée au banc de registres scalaire. Rien d'étonnant. L'unité SIMD peut aussi lire des scalaires pour certaines instructions SIMD verticales, ce qui fait qu'elle est aussi connecté au banc de registres scalaires. [[File:MIAOW GPU diagram.png|centre|vignette|upright=2|Microarchitecture d'un GPU, avec un focus sur un processeur de shader.]] ==Le ''multithreading'' matériel des processeurs de shaders== L'unité d'''issue'' détecte les dépendances de données, et bloque les instructions si elles ne doivent pas s'exécuter. L'inconvénient est que, quand une instruction est bloquée, les instructions suivantes sont aussi bloquées dans l'étage où elles sont. Rien ne progresse dans le pipeline tant que l'instruction fautive est bloquée. Heureusement, les GPU et les CPU disposent de techniques pour surmonter ce blocage, afin de continuer à exécuter des instructions. Les CPU disposent de techniques d'exécution dans le désordre, de renommage de registre, et bien d'autres. Mais leur implémentation demande un budget en transistor conséquent, que les GPU ne peuvent pas se permettre. A la place, ils utilisent une technique appelée le '''''multithreading'' matériel''', qui vient du monde des CPU. Vous connaissez sans doute l'''hyperthreading'' d'Intel ? C'est une version basique du ''multithreading'' matériel. L'idée est d'exécuter plusieurs programmes en même temps sur le même processeur, le processeur commutant de l'un à l'autre suivant les besoins. Par exemple, si un programme est bloqué par un accès mémoire, d'autres programmes exécutent des calculs dans l'unité de calcul en parallèle de l'accès mémoire. Pour un GPU, les programmes en question sont des instances de shader qui travaillent sur des données différentes. Ces instances de shader portent les noms de ''warp'' dans la terminologie NVIDIA, mais on peut aussi parler de ''threads'' pour utiliser la même terminologie que pour les CPUs. Un processeur de ''shader'' commute donc régulièrement d'un ''warp'' à l'autre, suivant les besoins. Dans ce qui va suivre, nous allons voir dans quelles situations un processeur de ''shader'' change de ''thread''/''warp'' en cours d'exécution. Suivant le GPU, les situations ne sont pas les mêmes. Il existe trois techniques de ''multithreading'' matériel : le ''Fine Grained Multithreading'', le ''Coarse Grained Multithreading'' et le ''Simultaneous MultiThreading''. Dans ce qui suit, nous utiliserons l'abréviation FGMT pour parler du ''Fine Grained Multithreading'', de CGMT pour parler du ''Coarse Grained Multithreading'' et de SMT pour le ''Simultaneous MultiThreading''. Les GPU ont d'abord implémenté le CGMT dans la période DirectX 9, puis sont passé au FGMT, avant de passer au SMT sur les générations récentes. Aussi, nous allons les voir dans l'ordre. ===Le ''Coarse Grained Multithreading'' de l'époque DirectX 9=== Les processeurs de shader sont connectés à une mémoire vidéo très lente, avec un temps d'accès élevé, qui se rattrape avec un débit binaire important. La conséquence est qu'un accès à une texture, c'est long : si celle-ci est lue depuis la mémoire vidéo, le temps d'attente est d'une bonne centaine de cycles d'horloges. Pour limiter la casse, les unités de texture incorporent un cache de texture, mais cela ne suffit pas toujours à alimenter les processeurs de shaders en données. Et ces derniers ne peuvent pas recourir à des techniques avancées communes sur les CPU, comme l’exécution dans le désordre : le cout en circuit serait trop important. [[File:Coarse Grained Multithreading.png|thumb|Coarse Grained Multithreading.]] Fort heureusement, les processeurs de shaders utilisent le ''multithreading'' matériel pour masquer la latence des accès mémoire. L'idée est que si un ''thread'' démarre un accès mémoire, il est mis en pause pendant l'accès mémoire, et laisse sa place à un autre ''thread''. Ainsi, pendant qu'un ''thread'' est bloqué par un accès mémoire, un autre ''thread'' utilise les unités de calcul en parallèle. Cela permet de masquer la latence des accès mémoire. : Notons qu'avec cette technique, les lectures mettent en pause le ''thread'' qui les exécute. On parle alors de '''lectures bloquantes'''. Nous verrons que les processeurs de shader plus récents exécutent des lectures non-bloquantes, mais ce sera pour la suite. La technique ne donne de bons résultats que si les accès mémoire sont peu fréquents, ou que le nombre de ''threads'' est élevé. Plus les accès mémoire sont fréquents, plus il faut un nombre de ''threads'' important pour masquer la latence. A l'époque, il était rare que les ''vertex shader'' accèdent à des textures, alors que les pixels shaders ne faisaient que ça. Ls processeurs de shaders de la Geforce 6 géraient au maximum 4 ''threads'' simultanés, alors que les processeurs de pixel shaders en géraient facilement plus d'une centaine. Les cartes concurrentes d'ATI supportaient 128 ''threads'' maximum, par processeur de pixel shader. L'implémentation de cette technique est assez simple, au premier abord. Déjà, il y a un ''Program Counter'' par ''thread''. À chaque cycle, un multiplexeur choisit le ''Program Counter'' - le thread - qui a la chance de charger ses instructions. Le choix du ''program counter'' sélectionné est le fait de l''''unité d'ordonnancement'''. L'unité d'ordonnancement sait quels ''threads'' sont en cours d'exécution et lesquels sont en pause. Pour cela, elle intègre une petite mémoire qui mémorise l'état de chaque ''thread''. [[File:Architecture d'un processeur multithreadé.png|centre|vignette|upright=2|Architecture d'un processeur multithreadé]] La technique impose cependant que les registres soient dupliqués, pour que chaque ''thread'' ait ses propres registres rien qu'à lui. Sans cela, impossible de passer rapidement d'un ''thread'' à l'autre à chaque cycle. Un processeur de ''shader'' peut exécuter entre 16 et 32 ''threads''/''warps'', ce qui multiplie le nombre de registres par 16/32. Et il y a la même chose avec d'autres structures matérielles, comme les files de lecture/écriture de l'unité d'accès mémoire. [[File:Aperçu de l'architecture d'un processeur multithreadé.png|centre|vignette|upright=2|Aperçu de l'architecture d'un processeur multithreadé]] : La technique générale porte le nom de ''Coarse Grained Multithreading''. C'est une forme de ''Multithreading'' où on change de programme quand un évènement bien précis a lieu. Les GPU en utilisaient une version où les évènements en question sont des accès mémoire. En théorie, la technique s'adapte pour d'autres opérations que les accès mémoire, tant que celles-ci prennent beaucoup de temps. Mais je ne saurais dire si les GPU l'appliquaient pour autre chose que les accès mémoire. ===Interlude propédeutique : le ''Fine Grained Multithreading''=== [[File:Fine Grained Multithreading.png|thumb|Fine Grained Multithreading]] Peu avant les années 2010, les processeurs de shaders ont subit quelques changements, afin d'augmenter leur performance. Notamment, le nombre d'étapes du pipeline a augmenté, histoire d’exécuter plus d'instructions simultanément. Et cela a commencé à poser quelques problèmes : les situations où deux instructions utilisent les mêmes registres a augmenté, les dépendances de données sont devenues un problème. Et pour résoudre ces problèmes, les GPU ont basculé du CGMT vers une forme de ''multithreading'' qu'on ne trouve que sur les GPU, ou presque. Et pour la comprendre, nous allons devoir faire un léger détour par une forme de ''multithreading'' très similaire, utilisée sur les CPU. Il s'agit du ''Fine Grained Multithreading'' (FGMT). Avec le FGMT, le processeur de shader change de ''thread'' à chaque cycle d'horloge. Le processeur fait donc une rotation, à chaque cycle, parmi les ''threads'' actifs. Les ''threads bloqués'' par un accès mémoire ne sont pas pris en compte. Par exemple, imaginons que le processeur gère 32 ''threads'' simultanés, mais que 8 d'entre eux soient en pause lors d'un accès mémoire. Dans ce cas, il changera de ''thread'' tous les 24 cycles, car il ne prend en compte que les ''threads'' non-bloqués. Faire ainsi a de nombreux avantages, notamment pour ce qui est des dépendances entre instructions. En changeant de ''thread'' à chaque cycle, on "espace" les instructions d'un même ''thread''. Par exemple, si on a 8 ''threads'' qui s'exécutent en même temps, alors il y a 8 cycles d'horloges entre deux instructions d'un même ''thread''. La première instruction a alors eu tout le temps pour enregistrer son résultat dans les registres, avant même que la seconde instruction lise ses opérandes. Le processeur peut alors utiliser un ''scoreboard'' très limité, très simple, pour détecter les rares dépendances de données qui restent, voire peut se passer complétement de ''scoreboard'' ! ===Le ''Multithreading'' commandé par le ''scoreboard'' des années 2005-2010=== [[File:Full multithreading.png|thumb|Full multithreading]] Sur les GPU récents, le processeur de shader ne change pas de ''thread'' à chaque cycle. Il préfère exécuter plusieurs instructions consécutives d'un même ''thread'', dans des cycles d'horloge consécutifs. Il ne change de ''thread'' que quand une dépendance de donnée bloque une instruction. C'est donc le ''scoreboard'' qui commande le changement de ''thread'', là où le changement de ''thread'' était réalisé sans lui avec le CGMT ou le FGMT. L'implémentation matérielle sépare le processeur de ''shader'' en deux sections séparées par une mémoire tampon qui met en attente les instructions. Les instructions sont chargées et décodées, puis sont placées dans une ou plusieurs '''files d'instruction'''. Le cas le plus simple à comprendre utilise une file d'instruction par ''thread''. A chaque cycle, l'unité d'émission vérifie plusieurs instructions, une par file d'instruction. Il choisit alors une instruction prête, puis l'envoie aux unités de calcul. L'instruction peut venir de n'importe quelle file d'instruction. Mais pour que cela fonctionne, il faut que les files d'instructions soient remplies, ce qui implique que le processeur doit avoir chargée des instructions en avance. Plus haut, nous avions dit que l'unité de chargement mémorise plusieurs ''program counter'', un par ''thread'', et choisit à chaque cycle l'un d'entre eux pour charger une instruction. Le choix en question est synchronisé avec l'émission des instructions. Si un ''thread'' émet une instruction, ce même ''thread'' charge une instruction au même moment. Sauf si la file d'instruction du ''thread'' est déjà pleine, auquel cas un autre ''thread'' est choisi. Précisons que le grand nombre de registres et de ''threads'' fait qu'un ''scoreboard'' classique, tel que décrit dans les cours d'architecture des ordinateurs, devient rapidement impraticable. Aussi, une implémentation alternative est utilisée, bien que les détails ne soient pas connus à ce jour. Quelques brevets donnent des détails d'implémentation, mais on ne sait pas s'ils sont à jour. Les curieux peuvent tenter de lire le brevet américain numéro #7,634,621, nommé ''"Register File Allocation"'', déposé par NVIDIA durant décembre 2009. [[File:FGMT sur un processeur de shaders.png|centre|vignette|upright=2|FGMT sur un processeur de shaders]] L'usage d'un ''scoreboard'' permet de nombreuses optimisations, qui portent notamment sur les lectures, qui interagissent avec le FGMT. L'optimisation en question s'appelle les '''lectures non-bloquantes'''. Elle fonctionne si la lecture est suivie par d'autres instructions qui n’accèdent pas à la mémoire, par exemple des instructions de calculs. L'idée est d'exécuter ces instructions en avance, pendant que la lecture récupère la donnée. C'est possible car la lecture utilise l'unité mémoire et le cache, mais laisse libre les unités de calcul et les registres. En clair, pendant qu'on lit une texture, on fait des calculs en parallèle dans les ALU. Mais la technique n'est possible que si les instructions de calcul n'utilisent pas la donnée en cours de lecture. Heureusement, le ''scoreboard'' détecte si cela arrive en surveillant les registres. La lecture charge une donnée dans un registre, appelé le registre de destination (sous entendu, de destination de la lecture). Les instructions qui n'utilisent pas ce registre peuvent s'exécuter sans problèmes : elles sont indépendantes de la lecture. Mais dès qu'une instruction souhaite lire ou écrire dans ce registre, elle est bloquée par le ''scoreboard'' et le processeur change de ''thread''. Détecter les dépendances demande juste de mémoriser le registre de destination dans un registre temporaire, et de regarder si l'instruction à exécuter utilise ce registre. Une différence avec le CGMT est le moment où un ''thread'' est bloqué par une lecture. Avec des lectures bloquantes, un ''thread'' est bloqué dès que la lecture est lancée. Et ce même si les instructions suivantes sont indépendantes de la lecture. Par contre, avec les lectures non-bloquantes, un ''thread'' n'est pas bloqué à ce moment-là. Il continue son exécution jusqu'à ce qu'une instruction accède à la donnée lue. Si la lecture s'est déjà terminée, alors la donnée est disponible et le ''thread'' continue de s'exécuter. Mais si la lecture est encore en cours, alors le processeur bloque l'instruction et change de ''thread''. Une autre optimisation possible est l'usage de l''''émission multiple'''. Avec elle, le ''scoreboard'' peut émettre deux instructions lors du même cycle d'horloge, si elles utilisent des unités de calcul différentes. Les deux instructions doivent faire partie du même ''thread'', mais certains GPU acceptent qu'elles soient de deux ''threads'' différents, tout dépend du GPU. Par exemple, il est possible qu'une instruction d'un ''thread'' utilise l'unité de calcul SIMD, alors que l'autre lance une lecture de texture. Ou encore, les deux peuvent lancer des calculs SIMD, mais dans deux unités SIMD séparées. ===L'encodage explicite des dépendances sur les GPU post-2010=== Depuis environ 2010, les GPU n'utilisent plus de ''scoreboard'' proprement dit. A la place, les GPU modernes déportent la détection des dépendances de données à la compilation. L'idée est que chaque instruction contient quelques bits pour dire au processeur : tu peux lancer 1, 2, 3 instructions à la suite sans problème. La technique porte le nom d''''anticipation de dépendances explicite''' (''Explicit-Dependence lookahead''). Un exemple historique assez ancien est le processeur Tera MTA (''MultiThreaded Architecture''), qui utilisait cette technique. Les GPU NVIDIA modernes utilisent plusieurs bits par instruction pour gérer les dépendances de données. Un premier mécanisme est utilisé pour bloquer l'émission d'une nouvelle instruction pendant x cycles. Il utilise un '''''stall counter''''', qui mémorise le nombre de cycles d'attente. Une instruction peut initialiser le ''stall counter'' avec une valeur de base, qui indique combien de cycles attendre. Le ''stall counter'' est décrémenté à chaque cycle d'horloge et une nouvelle instruction s'exécute seulement quand le compteur atteint 0. Il est utilisé quand une instruction productrice prenant X cycles est suivie par une instruction consommatrice, le ''stall counter'' est alors initialisé à la valeur X. La méthode précédente ne fonctionne que pour les instructions dont le compilateur peut prédire la durée. Les accès mémoire et certains calculs complexes ne sont pas dans ce cas. Pour les gérer, les GPU modernes utilisent un autre mécanisme, basé sur des '''compteurs de dépendances'''. Il y en a entre 5 et 10 selon le GPU. Une instruction dite productrice réserve un de ces compteurs, l'incrémente quand elle démarre son exécution/est émise, le décrémente quand la dépendance est résolue. Les instructions consommatrices, qui utilisent le résultat de l'instruction productrice, attendent que le compteur tombe à zéro pour s'exécuter. Elles précisent quels compteurs regarder avec un '''masque de compteurs de dépendance''', encodé directement dans l'instruction elle-même. Précisément, chaque instruction productrice se réserve deux compteurs, pour gérer les trois types de dépendances de données (RAW, WAR et WAW). Le premier compteur est décrémenté quand le résultat est écrit dans les registres, ce qui gère naturellement les dépendances RAW et WAW. Le second est décrémenté quand l'instruction a lu ses opérandes, ce qui gère les dépendances WAR. Les autres instructions regardent l'un ou l'autre des compteurs selon leur situation. Il arrive que le switch de ''thread'' soit déclenché par des bits intégrés dans l'instruction. Par exemple, sur les GPU NVIDIA modernes, chaque instruction contient un bit '''''yield''''' qui indique qu'il faut changer de ''thread'' une fois l'instruction émise. En clair, il indique que l'instruction risque de durer longtemps, a des dépendances avec la mémoire ou autre chose qui fait qu'il est préférable de changer de ''thread''. ==Le banc de registres d'un processeur de ''shader''== Les GPU disposent d'un grand nombre de registres. Les normes de DirectX et Open GL imposent que les shaders modernes gèrent au moins 4096 registres généraux par instance de shader, avec des registres spécialisés en plus. En soi, 4096 est énorme ! Mais au-delà de ces normes, le FGMT implique de dupliquer des registres par le nombre de ''threads hardware'' simultanés. Avec le FGMT, les registres devront être dupliqués pour que chaque ''thread'' ait ses propres registres rien qu'à lui. Sans cela, impossible de passer rapidement d'un ''thread'' à l'autre à chaque cycle. Maintenant, faisons quelques calculs d'épiciers. Un processeur de ''shader'' peut exécuter entre 16 et 32 ''threads''/''warps'', ce qui multiplie le nombre de registres par 16/32. En multipliant par les 4096 registres nécessaires, cela fait 128 kilooctets de mémoire rien que pour les registres. Et c'est pour un seul cœur ! Si on multiplie par le nombre de cœurs, on trouve que les cartes graphiques modernes ayant plusieurs processeurs de ''shaders'' ont facilement entre 32 768 et 65 536 registres de 32 bits, ce qui est énorme ! Il y a plus de mémoire gaspillée dans les registres que dans le cache L1 ou L2 ! Et ce grand nombre de registres par cœur pose quelques problèmes. Les registres sont regroupés dans une petite mémoire SRAM, adressable, appelée le '''banc de registre'''. Et comme toutes les mémoires, plus ce banc de registres est grand, plus il est lent. En conséquence, lire un opérande dans les registres prend beaucoup de temps. Du moins, c'est le cas sans optimisations. Et les GPU implémentent de nombreuses parades pour limiter le nombre de registres réellement présents dans leur silicium. ===L'allocation dynamique/statique des registres par ''thread''=== Les GPU modernes n'implémentent pas le nombre maximal de registres demandés. Par exemple, si je prends les GPU AMD de type RDNA 4, il peuvent gérer 16 ''threads'' hardware simultanés, chacun ayant accès à 256 registres, registres faisant 128 octets chacun. On s'attend à avoir un banc de registre de 16 * 256 * 128 octets, soit 512 Kilo-octets. En somme, un banc de registre de la taille du cache L1. Sauf que les GPUs en question intègrent moins de registres que prévu ! Ils ont précisément une taille de 192 kilo-octets, soit 96 registres pour chacun des 16 ''threads''. En effet, 256 registres est un nombre maximal, que la plupart des ''shaders'' n'utilise pas totalement. La plupart des ''shaders'' utilise entre 64 et 128 registres, rarement moins, rarement plus. Aussi, le GPU partitionne le banc de registres à la demande entre les ''threads'', en leur donnant seulement une partie des registres. Le partitionnement peut être pseudo-statique, à savoir que le banc de registre est découpé en parts égales pour chaque ''thread'', ou dynamique avec un nombre de registre variant d'un ''thread'' à l'autre, selon les besoins. Prenons l'exemple d'un '''partitionnement pseudo-statique''', avec l'exemple des GPU AMD RDNA 1 et 2. Leur banc de registre fait 1024 registres, de 128 octets chacun, soit 128 KB au total. Le GPU gère 16 ''threads'' simultanés maximum. Avec un seul ''thread'' d'exécuté, le ''thread'' unique peut utiliser les 1024 registres du banc de registre pour lui tout seul. Avec deux ''threads'', chacun aura droit à 512 registres, soit la moitié du cas précédent. Avec 16 ''thread'' simultanés, chaque ''thread'' a accès à 64 registres, pas plus. Et ainsi de suite : le nombre de registre par ''thread'' est égal à la taille du banc de registre divisée par le nombre de ''threads''. A l'heure où j'écris ces lignes, courant 2025, les GPU Intel se contentent d'un partitionnement statique très limité. Avant les GPU d'architecture Battlemage, il n'y avait pas de partitionnement du banc de registre, tous les ''threads'' avaient 128 registre à leur disposition. Les GPU Battlemage et ses successeurs ont introduit un partitionnement limité, avec deux modes : un mode sans partitionnement où tous les ''threads'' ont accès à 128 registres, un mode avec partitionnement qui divise le nombre de ''thread'' par deux et leur donne chacun 256 registres. Pas de possibilité de diviser plus le nombre de ''threads''. Les GPU AMD et NVIDIA sont eux plus compétents niveau partitionnement statique. Par exemple, les GPU RDNA 4 supportent les partitionnements suivants : * 16 ''threads'' avec 96 registres chacun ; * 12 ''threads'' avec 120 registres chacun ; * 10 ''threads'' avec 144 registres chacun ; * 9 ''threads'' avec 168 registres chacun ; * 8 ''threads'' avec 192 registres chacun ; * 7 ''threads'' avec 216 registres chacun ; * 6 ''threads'' avec 240 registres chacun ; * 5 ''threads'' avec 256 registres chacun. Le partitionnement pseudo-statique est simple à implémenter, il ne demande pas beaucoup de circuits pour fonctionner. Il est rapide et a de bonnes performances pour le rendu graphique en rastérisation. La raison est qu'en rastérisation, les différents ''threads'' sont souvent des copies/instances d'un même ''shader'' qui travaillent sur des données différentes. Leur donner le même nombre de registres colle bien avec cet état de fait. Cependant, si les différents ''threads'' sont des ''shaders'' différents, les choses ne sont pas optimales. Un ''shader'' utilisera plus de registres que l'autre, leur donner le même nombre de registres n'est pas optimal. Par exemple, imaginons que l'on a deux shaders, nommés shaders 1 et 2, aux besoins différents, l'un étant gourmand en registres et l'autre très économe. Dans ce cas, il faudrait partitionner le banc de registre pour donner plus de registre au premier et moins au second. Il s'agit là d'un '''partitionnement dynamique'''. Le partitionnement dynamique est plus optimal pour gérer des ''shaders'' déséquilibrés niveau registres, mais a une implémentation matérielle plus complexe. Il a été introduit assez tard, car il a fallu attendre que le rayctracing se démocratise. En effet, le partitionnement dynamique du banc de registre est surtout utile pour le raytracing. Exécuter simultanément des shaders déséquilibrés en registres est peu fréquent avec la rastérisation, beaucoup plus courant avec le raytracing. NVIDIA et AMD ont des implémentations différentes du partitionnement dynamique. Les GPU AMD RDNA 4 allouent un nombre minimal de registre à chaque ''thread'', mais ils peuvent demander d'avoir accès à plus de registres si besoin. Quand un ''thread'' a besoin de plus de registres, il exécute une instruction dédiée, qui sert à demander plus de registres, ou au contraire à en libérer s'ils sont inutilisés. La demande d'allocation de nouveaux registres se fait par blocs de 16 à 32 registres, suivant comment est configuré le processeur. Précisons que l'instruction d'allocation n'est disponible que pour les ''compute shaders'' et pas les ''shaders'' graphiques. L'instruction d'allocation de registre précédent peut échouer dans certains cas. Si assez de registres sont disponibles, à savoir inutilisés par d'autres ''threads'', l'instruction réussit. Dans le cas contraire, elle échoue et le ''shader'' est mis en pause avant de retenter cette demande plus tard. Le résultat de l'instruction, échec ou réussite, est mémorisé dans le registre d'état. La technique a pour défaut que certaines situations peuvent mener à un blocage complet du processeur, où chaque ''thread'' ne peut plus poursuivre son exécution, faute de registres disponibles. Des méthodes pour éviter cette situation sont implémentés sur ces GPU, mais la documentation n'est pas très explicite. Sur les GPU NVIDIA, il y a aussi une instruction d'allocation de registre, mais elle fonctionne différemment. Elle permet d'échanger des registres entre ''threads''. Une première différence est que tous les ''threads'' commencent avec une allocation égale des registres. Les ''threads'' démarrent tous avec le même nombre de registres. Un ''thread'' peut libérer des registres, qui sont alors alloués à un autre ''thread'', le ''thread'' en question pouvant être choisit par le ''thread'' qui libère les registres. ===Le banc de registre est multiport de type externe=== Le banc de registre doit permettre de lire deux vecteurs SIMD par opération, soit deux lectures simultanées. Pour cela, le banc de registre contient deux ports de lecture, chacun permettant de lire un opérande dans le banc de registre. Mais plus le nombre de ports augmente, plus la consommation énergétique du banc de registre augmente, sans compter que celui-ci devient plus lent. Les processeurs peuvent utilisent des bancs de registres ayant réellement deux ports par banc de registre. Un port de lecture est implémenté avec un composant appelé un multiplexeur, connecté à tous les registres. [[File:Mémoire multiport faite avec des MUX-DEMUX.png|centre|vignette|upright=2|Mémoire multiport faite avec des MUX-DEMUX]] Les GPU ne peuvent pas se permettre un tel luxe. Leur banc de registre doit alimenter plusieurs unités de calcul en même temps, en parallèle. Le nombre de ports serait plus proche de 4 à 10 ports de lectures. La solution précédente aurait un budget en transistor et un budget thermique trop important. À la place, ils utilisent une autre méthode : ils simulent un banc de registre à plusieurs ports à partir de plusieurs bancs de registres à un port. Le banc de registre est en réalité formé de plusieurs banques, de plusieurs bancs de registre séparés, chacun mono-port. L'idée est que si l'on accède à deux banques en même temps, on peut lire deux opérandes, une par port/banc de registre. Par contre, si les deux opérandes à lire sont la même banque, il y a un '''conflit d'accès aux banques'''. [[File:Mémoire à multiports par banques.png|centre|vignette|upright=2|Mémoire à multiports par banques.]] ===L'''Operand Collector'' et les caches de ''register reuse''=== Sans conflit d'accès à une banque, les deux opérandes sont disponibles immédiatement. Par contre, en cas de conflit d'accès aux banques, les deux opérandes sont lus l'une après l'autre. En clair, le premier opérande doit être mis en attente quelque part pendant que la seconde est en cours de lecture. Et le problème survient souvent, surtout avec les opérations FMA qui utilisent trois opérandes, encore plus avec les rares opérations qui demandent 4 à 5 opérandes. Pour gérer les conflits d'accès aux banques, les GPU utilise un circuit dédié, appelé le '''collecteur d'opérandes''' (''operand collector''). Le rôle du collecteur d'opérandes est d'accumuler les opérandes en attente, son nom est assez transparent. Il accumule les opérandes en attente, puis les envoie aux unités de calcul quand elles sont toutes prêtes, toutes lues depuis le banc de registres. Les opérandes sont mis en attente dans des entrées, qui contiennent la donnée, l'identifiant du ''thread''/''warp'' pour ne pas confondre des opérandes entre ''threads'', et deux bits d'occupation. Les deux bits d'occupation indiquent si l'entrée est vide, réservée pour un opérande en cours de lecture ou occupée par un opérande. Le collecteur d'opérande est souvent accompagné de '''registres temporaires''', qui mémorisent le résultat d'une instruction précédente. Il y a un registre temporaire par ALU, le résultat fournit par une ALU est mémorisé dans le registre temporaire associé. Une instruction peut lire une opérande dans un registre temporaire, ce qui permet de lire le résultat d'une instruction précédente sans passer par le banc de registres. Le collecteur d'opérande est alors configuré pour récupérer les opérandes adéquats dans les registres temporaires adéquats. : Pour faire une comparaison avec les processeurs modernes, ces registres sont une forme de ''data forwarding'', de contournement, mais qui est rendue explicite pour le logiciel. Une optimisation des GPU récent vise à réduire les accès aux bancs de registres en utilisant une mémoire cache spécialisée. Il s'agit de l'''operand reuse cache'', aussi appelés '''''register reuse cache'''''. L'idée est que quand un opérande est lu depuis le banc de registres, elle peut être stockée dans ce cache pour des utilisations ultérieures. Les architectures Volta, Pascal et Maxwell disposent de 4 caches de ce type, chacun stockant 8 données/opérandes/résultats Il s'agit en réalité de pseudo-caches, car ils sont partiellement commandés par le logiciel. Une instruction précise qu'un opérande doit être stocké dans un ''register reuse cache'', pour une utilisation ultérieure. Pour cela, elle incorpore quelques bits pour préciser qu'elle doit être placée dans le cache. Les bits font en quelque sorte partie du mode d'adressage. Si l'instruction immédiatement suivante lit ce registre dans le même ''slot'' d'opérande lira l'opérande dans le cache. La technique est donc assez limitée, mais elle a des résultats pas négligeables. Les ''register reuse cache'' et le collecteur opérandes sont sans doute fusionnés en un seul circuit, plutôt que d'utiliser deux circuits séparés. La raison est que les deux doivent mémoriser des opérandes les mettre en attente pour une utilisation ultérieure, et sont placés juste après le banc de registre. NVIDIA a publié deux brevets à propos de ces deux techniques, mais rien n'indique que c'est exactement cette technique qui utilisée dans les cartes modernes. Il faut dire que le nombre de banques a changé suivant les cartes graphiques. <noinclude> * [https://patents.google.com/patent/US7834881B2/en Operand collector architecture ] * [https://patents.google.com/patent/US20130159628A1/en Methods and apparatus for source operand collector caching] </noinclude> <noinclude> {{NavChapitre | book=Les cartes graphiques | prev=Les processeurs de shaders | prevText=Les processeurs de shaders | next=Les caches d'un processeur de shader | netxText=Les caches d'un processeur de shader }}{{autocat}} </noinclude> hmuasfjejp8begkx07xhngqrtkg85nk Les cartes graphiques/Avant les GPUs : les cartes accélératrices 3D 0 81913 765166 764880 2026-04-26T20:29:41Z Mewtow 31375 /* L'usage de plusieurs unités géométriques en parallèle */ 765166 wikitext text/x-wiki Dans le chapitre précédent, nous avons vu les bases du rendu 3D. Nous avons parlé de textures, de rastérisation, des calculs d'éclairage, et de bien d'autres choses. Vers la fin du chapitre, nous avons parlé des shaders, des programmes informatiques exécutés sur la carte graphique. Mais ils n'ont pas été toujours présents ! Les anciennes cartes graphiques faisaient sans shaders. Elles étaient autrefois appelées des '''cartes accélératrices 3D''', encore que la terminologie ne soit pas très précise.Nous les opposerons aux cartes graphiques capables d'exécuter des shaders, qui sont couramment appelées des '''Graphic Processing Units''', des GPUs. L'introduction des shaders a grandement modifié l'architecture des cartes graphiques. Il a fallu ajouter des processeurs pour exécuter les shaders, qui n'étaient pas là avant. Par contre, les circuits déjà présents ont été conservés, intégrés aux processeurs de shaders, ou remplacés par ceux-ci. D'un point de vue pédagogique, il est préférable de voir les cartes accélératrices 3D, avant de voir comment elles ont évolués vers des GPUs. Et nous allons voir cela dans deux chapitres. Ce chapitre portera sur les cartes accélératrices 3D, sans shaders, alors que le suivant expliquera comment s'est passée la transition vers les GPUs. : Nous allons nous concentrer sur les cartes graphiques à placage de texture inverse, le placage de texture direct ayant déjà été abordé dans le chapitre précédent. ==L'architecture d'une carte graphique 3D== Une carte accélératrice 3D est un carte d'affichage à laquelle on aurait rajouté des circuits de rendu 3D. Elle incorpore donc tous les circuits présents sur une carte d'affichage : un VDC, une interface avec le bus, une mémoire vidéo, des circuits d’interfaçage avec l'écran, un contrôleur DMA, etc. Le VDC s'occupe de l'affichage et éventuellement du rendu 2D, mais ne s'occupe pas du traitement de la 3D. Du moins, c'est le cas sur les cartes à placage de texture inverse. Le placage de texture direct utilise au contraire un VDC avec accélération 2D très performant, comme nous l'avons vu au chapitre précédent. Mais nous mettons ce cas particulier de côté. La carte accélératrice 3D reçoit des commandes graphiques, qui proviennent du pilote de la carte graphique, exécuté sur le processeur. les commandes en question sont très variées, avec des commandes de rendu 3D, de rendu 2D, de décodage/encodage vidéo, des transferts DMA, et bien d'autres. Mais nous allons nous concentrer sur les commandes de rendu 3D, qui demandent à la carte accélératrice 3D de faire une opération de rendu 3D. Pour cela, elles précisent quel tampon de sommet utiliser, quelles textures utiliser, quels shaders sont nécessaires, etc. La carte accélératrice 3D traite ces commandes grâce à deux circuits : des circuits de rendu 3D, et un chef d'orchestre qui dirige ces circuits de rendu pour qu'ils exécutent la commande demandée. Le chef d'orchestre s'appelle le '''processeur de commandes''', et il sera vu en détail dans quelques chapitres. Pour le moment, nous allons juste dire qu'il s'occupe de la logistique, de la répartition du travail. Pour les commandes de rendu 3D, il commande les différentes étapes du pipeline graphique et s'assure que les étapes s’exécutent dans le bon ordre. [[File:Architecture globale d'une carte 3D.png|centre|vignette|upright=2|Architecture globale d'une carte 3D]] Les circuits de rendu 3D regroupent des circuits hétérogènes, aux fonctions fort différentes. Dans le cas le plus simple, il y a un circuit pour chaque étape du pipeline graphique. De tels circuits sont appelés des '''unités de traitement graphique'''. On trouve ainsi une unité pour le placage de textures, une unité de traitement de la géométrie, une unité de rasterization, une unité d'enregistrement des pixels en mémoire appelée ROP, etc. Les anciennes cartes graphiques fonctionnaient ainsi, mais on verra que les cartes graphiques modernes font un petit peu différemment. Pour simplifier les explications, nous allons séparer la carte graphique en deux gros circuits bien distincts. En réalité, ils sont souvent séparés en sous-circuits plus petits, mais laissons cela de côté pour le moment. * Les '''unités géométriques''' pour les calculs géométriques ; * Les '''pipelines de pixel''' qui rastérisent l'image, plaquent les textures, et autres. Les unités géométriques manipulent des triangles, sommets ou polygones, donc des données géométriques. Les unités de pixel font tout le reste, mais le gros de leur travail est de manipuler des pixels ou des texels. Dans ce chapitre, on considère que les deux sont des circuits fixes, nous verrons leur évolution vers des processeurs programmables dans le prochain chapitre. ===Les circuits de traitement des pixels=== Parlons un peu plus en détail des pipelines de pixels. Pour mieux comprendre ce qu'elles font, il est intéressant de regarder ce qu'il y a dans un pipeline de pixel. Un pipeline de pixel effectue plusieurs opérations les unes à la suite, dans un ordre bien précis. Et cela explique l'usage du terme "pipeline" pour les désigner. Et ces opérations sont souvent réalisées par des circuits séparés, qui sont : * Un '''rastériseur''' qui fait le lien entre triangles et pixels ; * Une '''unité de texture''' qui lit les textures et les plaque sur les modèles 3D ; * Un '''ROP''' (''Raster Operation Pipeline''), qui gère grossièrement le tampon de profondeur (''z-buffer''). Le circuit de '''rastérisation''' prend en charge la rastérisation proprement dite. Pour rappel, la rastérisation projette une scène 3D sur l'écran. Elle fait passer d'une scène 3D à un écran en 2D avec des pixels. Lors de la rastérisation, chaque sommet est associé à un ou plusieurs pixels, à savoir les pixels qu'il occupe à l'écran. Elle fournit aussi diverses informations utiles pour la suite du pipeline graphique : la profondeur du sommet associé au pixel, les coordonnées de textures qui permettent de colorier le pixel. L'étape de '''placage de texture''' lit la texture associée au modèle 3D et identifie le texel adéquat avec les coordonnées textures, pour colorier le pixel. On travaille pixel par pixel, on récupère le texel associé à chaque pixel. Soit l'inverse du placage de texture direct, qui traversait une texture texel par texel, pour recopier le texel dans le pixel adéquat. Après l'étape de placage de textures, la carte graphique enregistre le résultat en mémoire. Lors de cette étape, divers traitements de '''post-traitement''' sont effectués et divers effets peuvent être ajoutés à l'image. Un effet de brouillard peut être ajouté, des tests de profondeur sont effectués pour éliminer certains pixels cachés, l'antialiasing est ajouté, on gère les effets de transparence, etc. Un chapitre entier sera dédié à ces opérations. [[File:Unité post-géométrie d'une carte graphique sans elimination des surfaces cachées.png|centre|vignette|upright=1.5|Unité post-1.5éométrie d'une carte graphique sans elimination des surfaces cachées]] ===Les circuits d'élimination des pixels cachés=== L'élimination des surfaces cachées élimine les triangles invisibles à l'écran, car cachés par un objet opaque. En théorie, elle est prise en charge à la toute fin du pipeline, dans les ROPs, car cela permet de gérer la transparence. En effet, on ne sait pas si une texture transparente sera plaquée sur le triangle ou non. En clair, on doit éliminer les triangles invisibles après le placage de textures, et donc dans les ROP. Les ROPs se chargent à la fois de l’élimination des pixels cachées et de la transparence, les deux s’influençant l'un l'autre. [[File:Unité post-géométrie d'une carte graphique avec elimination des surfaces cachées dans les ROPs.png|centre|vignette|upright=2|Unité post-géométrie d'une carte graphique avec élimination des surfaces cachées dans les ROPs]] Il y a cependant des cas où on sait d'avance que les textures ne sont pas transparentes. Dans ce cas, la carte graphique utilise les circuits d'élimination des pixels cachés juste après la rastérisation. Cela permet d'éliminer à l'avance les triangles dont on sait qu'ils ne seront pas rendus. [[File:Unité post-géométrie d'une carte graphique.png|centre|vignette|upright=2|Unité post-géométrie d'une carte graphique]] Les deux possibilités coexistent sur les cartes graphiques modernes. Une carte graphique moderne peut éliminer les surfaces cachées avant et après la rastérisation, grâce à des techniques d''''''early-z''''' dont nous parlerons plus tard, dans un chapitre dédié sur la rastérisation. ===Les circuits d'éclairage=== Les explications précédentes décrivent une carte graphique qui ne gère pas les techniques d'éclairage, et nous allons remédier à cela immédiatement. L'éclairage a été pris en charge avant même l'arrivée des shaders, dès les années 2000. Par contre, les cartes accélératrices pour PC géraient uniquement l'éclairage par sommet. Elles utilisaient un circuit non-programmable, appelé le '''circuit de ''Transform & Lightning''''', qui effectue les calculs d'éclairage par sommet (le L de T&L), en plus des calculs de transformation (le T de T&L). La première carte graphique à avoir intégré un circuit de T&L était la Geforce 256, la Geforce 1. L'unité de T&L a rapidement été remplacée par les ''vertex shader'', dont nous reparlerons d'ici quelques chapitres. Dès la Geforce 3, ce remplacement été effectué. L'unité de T&L calcule une couleur RGB pour chaque sommet/triangle, appelée la '''couleur de sommet'''. Une fois calculée par l'unité de T&L, la couleur de sommet est envoyée à l'unité de rastérisation. L'unité de rastérisation calcule la couleur des pixels à partir des trois couleurs de sommet. Pour cela, il y a deux méthodes principales, qui correspondent à l'éclairage plat et l'éclairage de Gouraud, qu'on a vu dans le chapitre précédent. Les cartes accélératrices utilisaient généralement l'éclairage de Gouraud. L'éclairage de Gouraud effectue une interpolation, à savoir une sorte de moyenne pondérée de la couleur des trois sommets. L'éclairage de Gouraud demande donc d'ajouter un circuit d'interpolation pour les couleurs des sommets. Il fait normalement partie du circuit de rastérisation, comme on le verra dans le chapitre dédié sur la rastérisation. Pour donner un exemple, la console de jeu Playstation 1 gérait l'éclairage de Gouraud directement en matériel, mais seulement partiellement. Elle n'avait pas de circuit de T&L, ni de ''vertex shaders'', mais intégrait une unité de rastérisation qui interpolait les couleurs de chaque sommet. Enfin, il faut prendre en compte les textures. Pour cela, le pixel texturé est multiplié par la luminosité/couleur calculée par l'unité géométrique. Il y a donc un '''circuit de combinaison''' situé après l'unité de texture qui effectue la combinaison/multiplication. Le circuit de combinaison est parfois configurable, à savoir qu'on peut remplacer la multiplication par une addition ou d'autres opérations. Un tel circuit de combinaison s'appelle alors un '''''combiner''''', dans la vieille nomenclature graphique de l'époque des années 90-2000. [[File:Implémentation de l'éclairage par sommet avec des combiners.png|centre|vignette|upright=2|Implémentation de l'éclairage par sommet avec des combiners]] Il a existé quelques rares cartes graphiques capables de faire de l'éclairage par pixel en matériel. Un exemple de carte graphique capable de faire cela est celle de la Nintendo DS, la PICA200. Créée par une startup japonaise, elle incorporait un circuit de T&L, un éclairage de Phong, du ''cel shading'', des techniques de ''normal-mapping'', de ''Shadow Mapping'', de ''light-mapping'', du ''cubemapping'', de nombreux effets de post-traitement (bloom, effet de flou cinétique, ''motion blur'', rendu HDR, et autres). Pour l'éclairage de Phong, il faut ajouter une unité qui fasse les calculs d'éclairage par pixel, et renvoie son résultat. La couleur de pixel calculée est ensuite combinée avec une texture, avec un ''combiner''. Du moins, si la carte accélératrice supporte les textures... Il faut aussi que le rastériseur interpole les normales, et non des couleurs de sommets comme avec l'éclairage de Gouraud. Les normales sont fournies par l'unité de T&L, ce qui demande une modification assez importante des unités de T&L et du rastériseur. [[File:Implémentation de l'éclairage par pixel avec des combiners.png|centre|vignette|upright=2|Implémentation de l'éclairage par pixel avec des combiners]] Voyons maintenant le ''bump-mapping'' et le ''normal-mapping''. Pour rappel, les deux dernières mémorisent des informations d'éclairage dans une texture en mémoire vidéo. La texture contient des informations de relief pour le ''bump-mapping'', des normales précalculées pour le ''normal-mapping''. Pour cela, l'unité d'éclairage par pixel doit être reliée à l'unité de texture, mais l'implémentation matérielle n'est pas aisée. [[File:Normal mapping matériel.png|centre|vignette|upright=2|Normal mapping matériel]] ==Les cartes graphiques avec plusieurs unités parallèles== Plus haut, nous avons décrit une carte graphique basique, très basique, avec seulement quatre unités. Une unité pour les calculs géométriques, un rastériseur, une unité pour les pixels/textures et un ROP. Cependant, les cartes graphiques ayant cette architecture sont très rares, pour ne pas dire inexistantes. Il n'est pas impossible que les toutes premières cartes graphiques aient suivi à la lettre cette architecture, mais même cela n'est pas sur. La raison : toutes les cartes graphiques dupliquent les circuits précédents pour gagner en performance, mais aussi pour s'adapter aux contraintes du rendu 3D. ===L'amplification des pixels et son impact sur les cartes graphiques=== Un triangle prend une certaine place à l'écran, il recouvre un ou plusieurs pixels lors de l'étape de rastérisation. Le nombre de pixels recouvert dépend fortement du triangle, de sa position, de sa profondeur, etc. Un triangle peut donner quelques pixels lors de l'étape de rastérisation, alors qu'un autre va couvrir 10 fois de pixels, un autre seulement trois fois plus, un autre seulement un pixel, etc. Le cas où un triangle ne recouvre qu'un seul pixel est rare, encore que la tendance commence à changer avec les jeux vidéos récents de la décennie 2020 utilisant l'Unreal Engine et la technologie Nanite. La conséquence est qu'il y a plus de travail à faire sur les pixels que sur les sommets, ce qui a reçu le nom d''''amplification des pixels'''. La conséquence est qu'une unité géométrique prendra un triangle en entrée, l'enverra au rastériseur, qui fournira en sortie un ou plusieurs pixels à éclairer/texturer. Et cette règle un triangle = 1,N pixels fait qu'il y a un déséquilibre entre les calculs géométriques et ce qui suit, que ce soit le placage de textures, l'éclairage par pixel ou l'enregistrement des pixels dans le ''framebuffer''. Et ce déséquilibre a un impact sur la manière dont un conçoit une carte graphique, ancienne comme moderne. S'il y a une seule unité de texture/pixels, alors le rastériseur envoie chaque pixel à texturer/éclairé un par un à l'unité de pixel. Le rastériseur produits ces pixels un par un, avec un algorithme adapté pour. L'unité géométrique attendra le temps que la rastérisation ait fini de traiter tous les pixels du triangle précédent. Elle calculera le prochain triangle pendant ce temps, mais cela ne fera que limiter la casse si beaucoup de pixels sont générés. Mais il est possible de profiter de l'amplification des pixels pour gagner en performances. L'idée est que le rastériseur produit plusieurs pixels en même temps, qui sont envoyés à plusieurs unités de texture et d'éclairage par pixel. Un exemple est illustré ci-dessous, avec une seule unité géométrique, mais quatre unités de texture, quatre unités d'éclairage par pixel, et quatre ROPs. Le rastériseur est conçu pour générer quatre pixels d'un seul coup si nécessaire. [[File:Architecture d'un GPU tenant compte de l'amplification des pixels.png|centre|vignette|upright=2.5|Architecture d'un GPU tenant compte de l'amplification des pixels]] La carte graphique précédente a des performances optimales quand un triangle recouvre 4 pixels : tout est fait en une seule passe. Si un triangle ne recouvre que 1, 2 ou 3 pixels, alors le rastériseur produira 1, 2 ou 3 et certaines unités suivant le rastériseur seront inutilisées. Mais si un triangle recouvre plus de 4 pixels, alors les pixels sont générés, texturés, éclairés et enregistrés en RAM par paquets de 4. En clair, la carte graphique peut s'adapter à l'amplification des pixels, mais pas parfaitement. Les GPU récents ont résolu partiellement ce problème avec un système de ''shaders'' unifiés, mais qu'on ne peut pas expliquer pour le moment. Pour donner un exemple du monde réel, les premières cartes graphique de l'entreprise SGI était de ce type. SGI a été une entreprise pinière dans le domaine du rendu en 3D, qui a opéré dans les années 80-90, avant de progressivement décliner et fermer. Elle a conçu de nombreux systèmes de type ''workstation'', donc destinés aux professionnels, avec des cartes graphiques dédiées. le grand public n'avait pas accès à ce genre de matériel, qui était très cher, vu qu'on n'était qu'au tout début de l'informatique. Nous ne détaillerons pas ces systèmes, car ils géraient leur mémoire vidéo d'une manière assez bizarre : elle était éclatée en plusieurs morceaux fusionnés chacun avec un ROP... Mais ils avaient tous une unité géométrique unique reliée à un rastériseur, qui alimentait plusieurs unités de texture/pixel et ROPs. Plus proche de nous, certaines cartes graphiques pour PC étaient aussi dans ce cas. Les toutes premières cartes graphiques pour PC n'avaient même pas de circuits géométriques, et se contentaient d'un rastériseur, d'unités de texture et de ROPs. Par la suite, la Geforce 256 a introduit une unité géométrique appelée l'unité de T&L. Les cartes graphiques de l'époque ont suivi le mouvement et ont aussi intégrée une unité géométrique presque identique. La Geforce 256 avait une unité géométrique, mais 4 unités de texture, 4 unités d'éclairage par pixel et 4 ROPs. ===Le multitexturing : dupliquer les unités de texture=== Le '''''multi-texturing''''' est une technique très importante pour le rendu 3D moderne. L'idée est de permettre à plusieurs textures de se superposer sur un objet. Divers effets graphiques demandent d'ajouter des textures par-dessus d'autres textures, pour ajouter des détails, du relief, sur une surface pré-existante. Un exemple intéressant vient des jeux de tir : ajouter des impacts de balles sur les murs. Pour cela, on plaque une texture d'impact de balle sur le mur, à la position du tir. Il s'agit là d'un exemple de ''decals'', des petites textures ajoutées sur les murs ou le sol, afin de simuler de la poussière, des impacts de balle, des craquelures, des fissures, des trous, etc. Le ''multi-texturing'' implique que calculer un pixel implique de lire plusieurs textures. En général, un pixel avec ''multi-texturing'' demande de lire deux textures, rarement plus. La carte graphique doit alors être capable d'accéder à deux textures en même temps, ou du moins faire semblant que. De plus, elle doit combiner les deux textures pour générer le pixel voulu, ce qui demande d'ajouter un circuit qui combine deux texels (des pixels de texture) pour donner un pixel. La solution la plus simple est de doubler les unités de texture et de combiner les textures dans l'unité d'éclairage par pixel. Résultat : pour une unité d'éclairage par pixel, on a deux unités de textures. La Geforce 2 et 3 utilisaient cette solution, dont le seul défaut est que la seconde unité de texture était utilisée seulement pour les objets sur lesquels le ''multi-texturing'' était utilisé. Les cartes ATI, le concurrent de l'époque de NVIDIA, aujourd'hui racheté par AMD, triplait les unités de texture. Mais cette possibilité était peu utilisée, la majorité des jeux se dépassant pas deux texture max par pixel. C'est sans doute pour cette raison que ce triplement a été abandonné à la génération suivante, les Radeon 9000 et 8500 se contentant de doubler les unités de texture. {|class="wikitable" |- ! Nom de la carte graphique !! Unités géométriques !! Unité de texture !! Unités de pixel !! ROPs |- ! Geforce 2 d'entrée de gamme | 1 || 2 || 4 || 2 |- ! Geforce 2 milieu/haut de gamme, Geforce 3 | 1 || 4 || 8 || 4 |- ! Radeon R100 bas de gamme | 1 || 1 || 3 || 1 |- ! Radeon R100 autres | 1 || 2 || 6 || 2 |} ===L'usage de plusieurs unités géométriques=== Pour encore augmenter les performances, il est possible d'utiliser plusieurs circuits de calcul géométriques, plusieurs unités géométriques. Et ce peu importe que ces unités soient des processeurs ou des circuits fixes non-programmables. Et pour cela, il existe deux grandes implémentations : utiliser plusieurs processeurs placés en série, ou les mettre en parallèle. Comprendre la première implémentation demande de faire quelques rappels sur les calculs géométriques. ====L'usage d'un pipeline géométrique proprement dit==== Pour rappel, le pipeline géométrique regroupe les quatre étapes suivantes : * L'étape de '''chargement des sommets/triangles''', qui sont lus depuis la mémoire vidéo et injectés dans le pipeline graphique. * L'étape de '''transformation''' effectue deux changements de coordonnées pour chaque sommet. ** Premièrement, elle place les objets au bon endroit dans la scène 3D, ce qui demande de mettre à jour les coordonnées de chaque sommet de chaque modèle. C'est la première étape de calcul : l'''étape de transformation des modèles 3D''. ** Deuxièmement, elle effectue un changement de coordonnées pour centrer l'univers sur la caméra, dans la direction du regard. C'est l'étape de ''transformation de la caméra''. * La phase d''''éclairage''' (en anglais ''lighting'') attribue une couleur à chaque sommet, qui définit son niveau de luminosité : est-ce que le sommet est fortement éclairé ou est-il dans l'ombre ? * La phase d''''assemblage des primitives''' regroupe les sommets en triangles. * Les phases de '''''clipping''''' ou le '''''culling''''' agissent sur des sommets/triangles/primitives, même si elles sont souvent regroupées dans l'étape de rastérisation. Si on met de côté le chargement des sommets/triangles, il est possible de faire tous ces calculs en bloc, dans un seul processeur ou une seule unité de T&L. Mais une autre idée, plus simple, attribue un processeur/circuit pour chaque étape. En faisant cela, on peut traiter plusieurs triangles/sommets en même temps, chacun étant dans une étape différente, chacun dans un processeur/circuit. Ceux qui auront déjà lu un cours d'architecture des ordinateurs reconnaitront la fameuse technique du pipeline, mais appliquée ici à un algorithme plus conséquent. Les processeurs sont en série, et chaque processeur reçoit les résultats du processeur précédent, et envoie son résultat au processeur suivant. Sauf en début ou en bout de chaine, évidemment. Pour donner un exemple, les premières cartes graphiques de SGI utilisaient 10/12 processeurs enchainés l'un à la suite de l'autre. Les 4 premiers géraient les étapes de transformation, les 6 suivants faisaient les opérations de clipping/culling, les deux derniers faisaient la rastérisation proprement dite. Pour lisser les transferts de données, il est possible d'ajouter des mémoires FIFOs entre les processeurs. Comme ça, si un processeur est bloqué par un calcul un peu trop long, cela ne bloque pas les processeurs précédents. A la place, le processeur précédent accumule des résultats dans la mémoire FIFOs, qui seront consommé ultérieurement. En théorie, on peut s'attendre à ce que la performance soit multipliée par le nombre de processeurs. En réalité, les étapes sont rarement équilibrées, certaines étapes prennent beaucoup plus de temps que les autres, ce qui fait que la répartition des calculs n'est pas idéale : certains processeurs attendent que le processeur suivant ait finit son travail. De plus, l'organisation en pipeline entraine des couts de transmission/communication entre étapes, notamment si on utilise des mémoires FIFOs entre processeurs, ce qui est toujours le cas. Cette implémentation n'a été utilisée que sur les toutes premières cartes graphiques, avant l'apparition des PC grand public. Les systèmes SGI, utilisés pour des stations de travail, utilisaient cette architecture, par exemple. Mais elle est totalement abandonnée depuis les années 90. ====L'usage de plusieurs unités géométriques en parallèle==== La seconde solution utilise plusieurs unités géométriques en parallèle. Chaque unité géométrique traite un triangle/sommet de bout en bout, en faisant transformation, éclairage, etc. Mais vu qu'il y en a plusieurs, on peut traiter plusieurs triangles/sommets : un dans chaque unité géométrique. C'est la solution retenue sur toutes les cartes graphiques depuis les années 90. Mais la présence de plusieurs unités géométriques a deux conséquences : il faut alimenter plusieurs unités géométriques en triangles/sommets, il faut gérer l'envoi des triangles au rastériseur. Les deux demandent des solutions distinctes. La répartition du travail sur les unités géométriques est déléguée au processeur de commandes. Il utilise les unités géométriques à tour de rôle : on envoie le premier triangle à la première unité, le second triangle à la seconde unité, le troisième triangle à la troisième, etc. Il s'agit de ce que l'on appelle l''''algorithme du tourniquet''', qui est assez efficace malgré sa simplicité. Il marche assez bien quand tous les triangles/sommets mettent approximativement le même temps pour être traités. Si le temps de calcul varie beaucoup d'un triangle/sommet à l'autre, une solution toute simple détecte quels sont les processeurs de shaders libres et ceux occupés. Il suffit alors d'appliquer l'algorithme du tourniquet seulement sur les processeurs de shaders libres, qui n'ont rien à faire. Un autre problème survient cette fois-ci en sortie des unités géométriques. Comment connecter plusieurs unités géométriques au reste de la carte graphique ? Évidemment, la carte graphique contient plusieurs unités de texture/pixel et plusieurs ROPs. Elle tient compte de l'amplification des pixels, ce qui fait qu'il y a moins d'unités géométriques que d'autres circuits, entre 2 à 8 fois moins environ. Pour créer une carte graphique avec plusieurs unités géométriques, il y a plusieurs solutions, que nous allons détailler dans ce qui suit. Pour les explications, nous allons prendre l'exemple de cartes graphiques avec 2 unités géométriques et 8 unités de texture/pixel, et autant de ROPs. La première solution serait simplement de dupliquer les circuits précédents, en gardant leurs interconnexions. Pour l'exemple, on aurait 2 unités géométriques, chacune connectée à 4 unités de textures/pixels. L'unité géométrique est suivie par un rastériseur qui alimente 4 unités de texture/pixel, comme c'était le cas dans la section précédente. L'implémentation est alors très simple : on a juste à dupliquer les circuits et à modifier le processeur de commande. Il faut aussi modifier les connexions des ROPs à la mémoire vidéo. Mais les interconnexions avec le rastériseur ne sont pas modifiées. Un désavantage est que l'amplification des pixels n'est pas gérée au mieux. Imaginez que l'on ait deux triangles à rastériser, qui génèrent 8 pixels en tout : un qui génère 6 pixels à la rastérisation, l'autre seulement 2. Il n'est pas possible de traiter les 8 pixels générés. Le triangle générant deux pixels va alimenter deux unités de texture/pixels et en laisser deux inutilisées, l'autre triangle sera traité en deux fois (4 pixels, puis 2). La duplication bête et méchante n'utilise donc pas à la perfection les unités de texture/pixel. Une autre solution permet de gérer à la perfection l'amplification des pixels. Elle consiste à utiliser un seul rastériseur à haute performance, sur lequel on connecte les unités géométriques et les unités de texture/pixel. L'idée est que le rastériseur peut recevoir N triangles à la fois et alimenter M unités de texture/pixels. Le rastériseur unique s'occupe de faire plusieurs rastérisations de triangles à la fois, et répartit automatiquement les pixels générés sur les unités de texture/pixel. Pour donner un exemple, le GPU Geforce 6800 de NVIDIA avait 6 unités géométriques, 16 unités faisant à la fois placage de textures et éclairage par pixel, et 16 ROPs. Un point important avec ce GPU est qu'il n'avait qu'un seul rastériseur, détail sur lequel on reviendra dans ce qui suit ! ==Les cartes graphiques en mode immédiat et à tuile== Il est courant de dire qu'il existe deux types de cartes graphiques : celles en mode immédiat, et celles avec un rendu en tuiles (''tiles''). Il s'agit là des deux types principaux de cartes graphiques à l'heure actuelle, mais quelques architectures faisaient autrement dans le passé. Une autre classification, plus générale, sépare les cartes graphiques en cartes graphiques ''sort-last'', ''sort-first'' et ''sort-middle''. Les cartes graphiques en mode immédiat correspondent aux cartes graphiques en mode immédiat, alors que le rendu à tuile est une sous-catégorie des cartes graphiques ''sort-middle''. La différence entre les deux est liée à la manière dont les pixels/primitives sont réparties sur l'écran. Leur existence est liée au fait que les API graphiques imposent que les triangles envoyées à la carte graphique soient traités dans l'ordre. Le tampon de sommets contient en effet une liste de sommets/triangles, qui sont censés être traités dans l'ordre d'arrivée. Et si je dis censé être, c'est parce que la carte graphique ne va pas forcément traiter les triangles/pixels dans l'ordre. A la place, elle va traiter des triangles/pixels en parallèle, et il n'est pas garantit que les résultats sortent des circuits dans l'ordre d'arrivée. Après tout, certains triangles sont traités plus rapidement que d'autres, idem pour les pixels. La carte graphique doit donc remettre les résultats dans l'ordre. L'endroit du pipeline où se fait cette remise en ordre est ce qui fait la différence entre cartes graphioques ''sort last'' et ''sort middle''. ===Les trois types de cartes graphiques : ''sort-first'', ''sort-middle'' et ''sort-last''=== Les cartes graphiques ''sort-first'' ont plusieurs pipelines séparés, chacun traitant une partie de l'écran. Ils déterminent la position des triangles à l'écran, puis répartissent les triangles dans les pipelines adéquats. Par exemple, on peut imaginer un GPU ''sort-first'' avec quatre unités séparées, chacune traitant un quart de l'écran. Au tout début du rendu, une unité de répartition détermine la position d'un triangle à l'écran, et l'envoie à l'unité adéquate. Si le triangle est dans le coin inférieur gauche, il sera envoyé à l'unité dédiée à ce coin. S'il est situé au milieu de l'écran, il sera envoyé aux quatre unités, chacune ne traitant les pixels que pour son coin à elle. Les cartes graphiques ''sort-middle'' découpent l'écran en carrés de 4, 8, 16, 32 pixels de côté , qui sont rendus séparément les uns des autres. Les morceaux d'image en question sont appelés des ''tiles'' en anglais, mot que nous avons décidé de ne pas traduire pour ne pas le confondre avec les tuiles du rendu 2D. Il y a une assignation stricte entre une unité de pixel/texture et une ''tile''. Par exemple, sur un système avec deux unités de texture/pixel, la première unité traitera les ''tiles'' paires, l'autre unité les ''tiles'' impaires. Les cartes graphiques ''sort-last'' sont l'extrême inverse. Ils ont des unités banalisées qui se moquent de l'endroit où se trouve un pixel à l'écran. Leurs unités géométriques traitent des polygones sans se préoccuper de leur place à l'écran. Le rastériseur envoie les pixels aux unités de textures/ROPs sans se soucier de leur place à l'écran. Encore que quelques optimisations s'en mêlent pour profiter au mieux des caches de texture et des caches intégrés aux ROPs, mais l'essentiel est qu'il n'y a pas de répartition fixe. Il n'y a pas de logique du type : ce pixel ou ce triangle est à tel endroit à l'écran, on l'envoie vers telle unité de texture/ROP. Ce sont les ROPs qui se chargent d'enregistrer les pixles finaux au bon endroit dans le ''framebuffer''. La gestion de la place des pixels à l'écran se fait donc à la toute fin du pipeline, d'où le nom de ''sort-last''. Pour résumer, les trois types de cartes graphiques se distinguent suivant l'endroit où les triangles/pixels sont répartis suivant leur place à l'écran. Avec le ''sort-first'', ce sont les triangles qui sont triés suivant leur place à l'écran. Le tri a donc lieu avant les unités géométriques. Avec le ''sort-middle'', ce sont les fragments générés par la rastérisation qui sont triés suivant leur place à l'écran, d'où l'existence de ''tiles''. Le tri a lieu entre les unités géométriques et le rastériseur. Les unités géométriques se moquent de la place à l'écran des primitives qu'ils traitent, mais pas les rastériseurs et les unités de texture. Enfin, avec le ''sort-last'', ce sont les pixels finaux qui sont triés selon leur place à l'écran, seuls les ROPs se préoccupent de cette place à l'écran. Concrètement, les cartes graphiques de type ''sort-first'' sont très rares, l'auteur de ce cours n'en connait aucun exemple. Les deux autres types de cartes graphiques sont eux beaucoup plus communs. Reste à voir ce qu'il y a à l'intérieur d'une carte graphique ''sort-middle'' et/ou ''sort-last''. Pour simplifier les explications, nous allons regrouper les circuits de traitement des pixels dans un seul gros circuits appelé le rastériseur, par abus de langage. La carte graphique est donc composée de deux circuits : l'unité géométrique et le mal-nommé rastériseur. Les cartes graphiques ajoutent des mémoires caches pour la géométrie et les textures, afin de rendre leur accès plus rapide. [[File:Carte graphique, généralités.png|centre|vignette|upright=2|Carte graphique, généralités]] ===Les cartes graphiques ''sort-last'', en mode immédiat=== Les cartes graphiques en mode immédiat implémentent le pipeline graphique d'une manière assez évidente. L'unité géométrique envoie des triangles au rastériseur, qui lui-même envoie les pixels à l'unité de texture, qui elle-même envoie le pixel texturé au ROP. Elles effectuent le rendu 3D triangle par tringle, pixel par pixel. Un point important est que pendant que le pixel N est dans les ROP, les pixels N+1 est dans l'unité de texture, le pixel N+2 est dans le rastériseur et le triangle suivant est dans l'unité géométrique. En clair, on n'attend pas qu'un triangle soit affiché pour en démarrer un autre. Un problème est qu'un triangle dans une scène 3D correspond souvent à plusieurs pixels, ce qui fait que la rastérisation prend plus de temps de calcul que la géométrie. En conséquence, il arrive fréquemment que le rastériseur soit occupé, alors que l'unité de géométrie veut lui envoyer des données. Pour éviter tout problème, on insère une petite mémoire entre l'unité géométrique et le rastériseur, qui porte le nom de '''tampon de primitives'''. Elle permet d'accumuler les sommets calculés quand le rastériseur est occupé. [[File:Carte graphique en rendu immédiat.png|centre|vignette|upright=2|Carte graphique en rendu immédiat]] Le tout peut s'adapter à la présence de plusieurs unités géométriques, de plusieurs unités de texture ou processeurs de shaders, tant qu'on conserve un rastériseur unique. Il suffit alors d'adapter le tampon de primitive et le rastériseur. Si on veut rajouter des unités de texture ou des processeurs de pixel shaders, le tampon de primitives n'est pas concerné : il suffit que le rastériseur ait plusieurs sorties, une par unité de texture/pixel shader. Par contre, la présence de plusieurs unités géométriques impacte le tampon de primitive. Avec plusieurs unités géométriques, il y a deux solutions : soit on garde un tampon de primitive unique partagé, soit il y a un tampon de primitive par unité géométrique. Avec la première solution, toutes les unités géométriques sont reliées à un tampon de primitives unique. Le tampon de primitive est conçu pour qu'on puisse écrire plusieurs primitives dedans en même temps. Le rastériseur n'a pas à être modifié. Une autre solution utilise un tampon de primitive par unité géométrique. Le rastériseur peut alors piocher dans plusieurs tampons de primitive, ce qui demande de modifier le rastériseur. Il y a alors un système d'arbitrage, pour que le rastériseur pioche des primitives équitablement dans tous les tampons de primitive, pas question que l'un d'entre eux soit ignoré durant trop longtemps. ===Les cartes graphiques ''sort-middle'' des années 90=== Voyons maintenant les architectures ''sort-middle'' utilisée dans les années 80-90, à une époque où les cartes graphiques grand public n'existaient pas encore. Les cartes graphiques de l’entreprise SGI sont dans ce cas, mais aussi le Pixel Planes 5, et de nombreux autres systèmes graphiques. Elles utilisaient un rendu à ''tile'' assez original. Dans ce qui suit, nous allons décrire l'architecture des systèmes SGI, qui sont représentatifs. L'idée était que l'image était découpée en un nombre de ''tiles'' qui variait selon le système utilisé, mais qui était au minimum de 5 et pouvait aller jusqu'à 20. Et chaque ''tile'' avait sa propre unité de traitement, qui contenait un rastériseur, une unité de texture, un ROP, etc. En clair, la carte graphique contenait entre 5 et 20 unités de traitement séparées, chacune dédiée à une ''tile''. Les triangles sortant des unités géométriques étaient envoyés à toutes les unités de traitement, sans exception. Une fois le triangle réceptionné, l'unité de traitement déterminait si le triangle s'affichait dans la ''tile'' associée ou non. Si c'est le cas, le rastériseur rastérise le triangle, génère les pixels, les textures sont lues, puis le tout est enregistré en mémoire vidéo. Si ce n'est pas le cas, elle abandonne le polygone/triangle reçu. Si le triangle est partiellement dans la ''tile'', le rastériseur génère les pixels qui sont dans la ''tile'', par les autres. Précisons que les cartes de ce style incorporaient un tampon de primitive, ce qui permettait de simplifier la conception de la carte graphique. Sur la carte ''Infinite Reality'', le tampon de primitive faisait 4 méga-octets de RAM, ce qui permettait de mémoriser 65 536 sommets. Sur la carte ''Reality Engine'', il y avait même plusieurs tampons de primitives, un par unité géométrique. Les polygones sortaient des unités géométriques, étaient accumulés dans les tampons de primitives, puis étaient ''broadcastés'' à toutes les unités de traitement. Pour cela, le bus en bleu dans le schéma précédent est en réalité un réseau ''crossbar'' avec un système de ''broadcast''. Une caractéristique de ces architectures est qu'elles mettent le ''framebuffer'' à part de la mémoire vidéo. De plus, ce ''framebuffer'' est lui-même découpée en ''tile''. Sur la carte ''Reality Engine'', le ''framebuffer'' est découpé en 5 à 20 sous-''framebuffer'', un par ''tile''. Et chaque mini-''framebuffer'' est placé dans l'unité de traitement de la ''tile'' associée ! Ainsi, au lieu de connecter 5-20 ROPs à une mémoire vidéo unique, chaque ROP contient une '''''RAM tile''''', qui mémorise la ''tile'' en cours de traitement. Évidemment, cela pose quelques problèmes pour la connexion au VDC, en raison de l'absence de ''framebuffer'' unique, mais rien d'insurmontable. L'architecture est illustrée ci-dessous. : Le Pixel Planes 5 avait un système similaire, mais avait en plus un ''framebuffer'' complet, dans lequel les sous-''framebuffer'' étaient recopiés pour obtenir l'image finale. [[File:Architecture des premières cartes graphiques SGI.png|centre|vignette|upright=2|Architecture des premières cartes graphiques SGI]] Un autre détail de l'architecture est lié à la mémoire pour les textures. Les concepteurs de SGI ont décidé de séparer les textures dans une mémoire à part du reste de la mémoire vidéo. Il n'y a pour ainsi dire pas de mémoire vidéo proprement dit : la géométrie à rendre est dans une mémoire à part, idem pour les textures, et pour le ''framebuffer''. On s'attendrait à ce que la mémoire de texture soit reliée aux 5-20 unités de texture, mais les concepteurs ont décidé de faire autrement. A la place, chaque unité de texture contient une copie de la mémoire de texture, qui est donc dupliquée en 5-20 exemplaires ! Difficile de comprendre la raison de ce choix, mais cela simplifiait sans doute les interconnexions internes de la carte graphique, au prix d'un cout en RAM assez important. ===Les cartes graphiques à rendu à ''tile''=== Les cartes graphiques de SGI, vus précédemment, disposent d'une unité de traitement par ''tile''. Faire ainsi permet de nombreuses optimisations, comme éclater le ''framebuffer'' en plusieurs ''RAM tile''. Mais le cout en matériel est conséquent. Pour économiser des circuits, l'idéal serait d'utiliser moins d'unités de traitement pour les pixels/fragments/textures. Mais pour cela, il faut profondément modifier l'architecture précédente. On perd forcément le lien entre une unité de traitement et une ''tile''. Et cela impose de revoir totalement la manière dont les unités géométriques communiquent avec les unités de traitement. La solution retenue est celle des cartes graphiques à rendu en ''tile'' proprement dit, aussi appelés ''cartes graphiques TBR'' (''Tile Based Rendering''). Les plus simples n'utilisent qu'une seule unité de traitement et n'ont qu'une seule ''RAM tile''. En conséquence, les ''tiles'' sont rendues l'une après l'autre. Au lieu de rendre chaque triangle/polygone l'un après l'autre, la géométrie est intégralement rendue avant de faire la rastérisation. Les triangles sont enregistrés dans la mémoire vidéo et regroupés par ''tile'', avant la rastérisation. La mémoire vidéo contient donc plusieurs paquets de triangles, avec un paquet par ''tile''. Les paquets/''tiles'' sont envoyées au rastériseur un par un, la rastérisation se fait ''tile'' par ''tile''. La ''RAM tile'' existe toujours, même si son utilité est différente. La ''RAM tile'' accélère le rendu d'une ''tile'', car tout ce qui est nécessaire pour rendre une ''tile'' est mémorisé dedans : la ''tile'', le tampon de profondeur, le tampon de stencil et plein d'autres trucs. Pas besoin d’accéder à un gigantesque z-buffer pour toute l'image, juste d'un minuscule z-buffer pour la ''tile'' en cours de traitement, qui tient totalement dans la SRAM. : Il faut noter que les ''tiles'' sont généralement assez petites : 16 ou 32 pixels de côté, rarement plus. En comparaison, les ''tiles'' faisaient 128 pixels de côté pour les cartes de SGI. [[File:Carte graphique en rendu par tiles.png|centre|vignette|upright=2|Carte graphique en rendu par tiles]] Il est possible pour une carte graphique TBR de traiter plusieurs ''tiles'' en même temps, en parallèle, dans des unités séparées. Un exemple est celui du GPU ARM Mali 400, qui dispose d'une unité géométrique (un processeur de ''vertex''), mais 4 processeurs de pixels. Il peut donc traiter quatre ''tiles'' en même temps, chacune étant rendue dans un processeur de pixel dédié. Les 4 processeurs de pixels ont chacun leur propre ''RAM tile'' rien qu'à eux. La présence d'une ''RAM tile'' a de nombreux avantages et impacte grandement l'architecture de la carte graphique. En premier lieu, les ROPs sont drastiquement modifiés. De nombreux GPU TBR n'ont même pas de ROPs ! A la place, les ROPs sont émulés par les processeurs de pixel shader. Les ''pixel shaders'' peuvent lire ou écrire directement dans le ''framebuffer'', sur les GPU TBR, ce qui leur permet d'émuler les ROPs avec des instructions mathématique/mémoire. Le ''driver'' patche automatiquement les ''pixel shader'' pour ajouter de quoi émuler les ROPs à la fin des ''pixel shaders''. Cela garantit une économie de circuits non-négligeable. La présence d'une ''RAM tile'' fait que le tampon de profondeur disparait. Par contre, les cartes graphiques de type TBR doivent enregistrer les triangles en mémoire vidéo, et les trier par paquets. Cela compense partiellement, totalement, ou sur-compense, les économies liées à la ''RAM tile''. Le regroupement des triangles par ''tile'' s'accompagne de quelques optimisations assez sympathiques. Par exemple, les GPU TBR modernes peuvent trier les triangles selon leur profondeur, directement lors du regroupement en paquets. L'avantage est que cela permet à l'élimination des pixels cachés de fonctionner au mieux. L'élimination des pixels cachés fonctionne à la perfection quand les triangles sont triés du plus proche au plus lointain, pour les objets opaques. Les cartes graphiques en mode immédiat ne peuvent pas faire ce tri, mais les cartes graphiques TBR peuvent le faire, soit totalement, soit partiellement. Un autre avantage est que l’antialiasing est plus rapide. Pour ceux qui ne le savent pas, l'antialiasing est une technique qui améliore la qualité d’image, en simulant une résolution supérieure. Une image rendue avec antialiasing aura la même résolution que l'écran, mais n'aura pas certains artefacts liés à une résolution insuffisante. Et l'antialiasing a lieu dans et après la rastérisation, et augmente la résolution du tampon de profondeur et du z-buffer. Les cartes graphiques en mode immédiat disposent d'optimisations pour limiter la casse, mais les ROP font malgré tout beaucoup d'accès mémoire. Avec le rendu en tiles, l'antialising se fait dans la ''RAM tile'', n'a pas besoin de passer par la mémoire vidéo et est donc plus rapide. ===Des compromis différents=== Les cartes graphiques des ordinateurs de bureau ou portables sont toutes en mode immédiat, alors que celles des appareils mobiles, smartphones et autres équipements embarqués ont un rendu en ''tiles''. Les raisons à cela sont multiples, mais la principale est que le rendu en ''tiles'' marche beaucoup mieux pour le rendu en 2D, comparé aux architectures en mode immédiat, ce qui se marie bien aux besoins des smartphones et autres objets connectés. La performance d'une carte graphique est limitée par la quantité d'accès mémoire par seconde. Autant dire que les économiser est primordial. Et les cartes en mode immédiat et par tile ne sont pas égales de ce point de vue. En mode immédiat, le tampon de primitives évite de passer par la mémoire vidéo, mais le z-buffer et le ''framebuffer'' sont très gourmand en accès mémoire. Avec les architectures à tile, c'est l'inverse : la géométrie est enregistrée en mémoire vidéo, mais le tampon de profondeur n'utilise pas la RAM vidéo. Au final, les deux architectures sont optimisées pour deux types de rendus différents. Les cartes à rendu en tile brillent quand la géométrie n'est pas trop compliquée, et que la résolution est grande ou que l'antialising est activé. Les cartes en mode immédiat sont douées pour les scènes géométriquement lourdes, mais avec peu d'accès aux pixels. Le tout est limité par divers caches qui tentent de rendre les accès mémoires moins fréquents, sur les deux types de cartes, mais sans que ce soit une solution miracle. ==La performance des anciennes cartes graphiques 3D== Intuitivement, la performance d'une carte graphique dépend de la performance de chacun de ses circuits : processeur de commande, mémoire vidéo, circuits de rendu 3D, VDC, etc. En pratique, il est rare qu'on soit limité par le VDC ou le processeur de commande. Les seules limitations viennent des circuits de rendu 3D et de la mémoire vidéo. Nous ne pouvons pas aborder la performance de la mémoire vidéo pour le moment. Tout ce que l'on peut dire est qu'il faut qu'elle soit assez rapide pour alimenter le rendu 3D en données. Les circuits de rendu 3D doivent lire des triangles et textures en mémoire vidéo, qui doit être assez rapide pour ça et ne pas les faire attendre. Pour le reste, voyons la performance des circuits de rendu 3D. Il ne nous est là aussi pas possible de détailler ce qui impacte la performance d'un GPU moderne. Dès que des processeurs de shaders sont impliqués, parler de performance demande de connaitre sur le bout des doigts les processeurs de shaders, ce qu'on n'a pas encore vu à ce stade du cours. Par contre, on peut détailler ce qu'il en était pour les anciennes cartes 3D, sans processeurs de shaders. Elles contenaient des ROPs, des unités de texture, un rastériseur et une unité géométrique (l'unité de T&L). Étudions d'abord la performance des unités de texture et des ROPs. Cela nous permettra de parler d'un paramètre qui avait son importance sur les anciennes cartes graphiques, avant les années 2000 : le ''fillrate''. Le '''''fill rate''''', ou taux de remplissage, est une ancienne mesure de performance autrefois utilisée pour comparer les cartes graphiques entre elles. Il s'agit d'une mesure assez approximative, au même titre que la fréquence d'horloge. Concrètement, plus il est élevé, meilleures seront les performances, en théorie. Mais attention : les petites différences de ''fillrate'' ne suffisent pas à rendre un verdict. De plus, il existe deux types distincts de ''fillrate'' : le ''Texture Fillrate'' et le ''Pixel Fillrate''. Voyons d'abord le ''Pixel Fillrate''. ===Le ''pixel fillrate'' : la performance des ROPs=== Le '''''pixel fillrate''''' est le nombre maximal de pixels que la carte graphique peut écrire en mémoire vidéo par seconde. Il est exprimé en ''Méga-Pixels par seconde'' ou en ''Giga-Pixels par seconde'', souvent abréviés en GP/s et MP/s. C'est une unité que vous croisez sans doute pour la première fois et qui mérite quelques explications. Premièrement, dans méga-pixels par seconde, il y a mégapixels. Il s'agit d'une unité pour compter le nombre de pixels d'une image. Un mégapixel signifie tout simplement un million de pixels, un gigapixel signifie un milliard de pixels. Je précise bien un million et un milliard, ce ne sont pas des multiples de 1024, comme on est habitué à en voir en informatique. Le nombre de pixels d'une image augmente avec la résolution utilisée, mais il reste de l'ordre du mégapixel, guère plus. Voici un tableau avec les résolutions les plus utilisées et le nombre de pixels associé. {|class="wikitable" |- ! Résolution !! Nombre de pixels |- | colspan="2" | |- | colspan="2" | Résolutions anciennes en 4:3 |- | 640 × 480 || 307 200 <math>\approx</math> 0,3 MP |- | 800 × 600 || 480 000 = 0,48 MP |- | 1 024 × 768 || 786 432 <math>\approx</math> 0,8 MP |- | 1 280 × 960 || 1 228 800 <math>\approx</math> 1,2 MP |- | 1 600 × 1 200 || 1 920 000 = 1,92 MP |- | colspan="2" | |- | colspan="2" | Résolutions modernes en 16:9 |- | 1 920 × 1 080 || 2 073 600 <math>\approx</math> 2 MP |- | 3 840 × 2 160 (4k) || 8 294 400 <math>\approx</math> 8.3 MP |} Maintenant, regardons ce qui se passe si on veut rendre plusieurs images par secondes. Intuitivement, on se dit qu'il faudra un ''pixel fillrate'' minimal pour cela. Et il se trouve qu'on peut le calculer aisément. Prenons par exemple une image en 1600 × 1200, de 1,92 mégapixels. Si on veut avoir 60 images par secondes, avec cette résolution, cela fait 1,92 * 60 mégapixels par secondes. En clair, le ''pixel fillrate'' minimal se calcule en multipliant la résolution par le ''framerate''. Le ''pixel fillrate'' minimal tourne autour de la centaine de mégapixels par seconde, voire approche le gigapixel par seconde en haute résolution. Les images font entre 1 et 10 mégapixels, pour environ 100 FPS, l'intervalle colle parfaitement. Maintenant, comparons un peu avec ce dont sont capables les GPUs. Les toutes premières cartes graphiques commerciales avaient un ''pixel fillrate'' proche de la centaine de méga-pixels par seconde. Pour donner un exemple, la Geforce 256 avait un ''pixel fillrate'' de 480 MP/s, la Geforce 3 faisait entre 700 et 960 MP/s selon le modèle. De nos jours, le ''pixel fillrate'' est de l'ordre de la centaine de Gigapixels. Pour donner un exemple, les Geforce RTX 5000 ont un ''pixel fillrate'' de 82.3GP/s pour la RTX 5050, à 423.6 GP/S pour la RTX 5090. Les GPU ont un ''pixel fillrate'' qui dépasse de très loin la valeur minimale, ce qui est franchement étrange. La raison à cela est que le ''pixel fillrate'' minimal se calcule sous l'hypothèse que chaque pixel de l'image finale ne sera écrit qu'une seule fois. Mais dans les faits, il est fréquent qu'un pixel soit dessiné plusieurs fois avant d'obtenir l'image finale. La raison principale est liée aux surfaces cachées. Si un objet est derrière un autre, il arrive que celui-ci soit dessiné dans le ''framebuffer'', avant que l'objet devant soit re-dessiné par-dessus. Des pixels ont alors été écrits, puis ré-écrits. Le fait de dessiner un pixel plusieurs fois porte un nom. Il s'agit d'un phénomène d''''''overdraw''''', ou sur-dessinage en français. Le sur-dessinage fait que le ''pixel fillrate'' minimal ne suffit pas en pratique. Pour éviter tout problème, le ''pixel fillrate'' du GPU doit être supérieur au ''pixel fillrate'' minimal, d'environ un ordre de grandeur. L'élimination des surfaces cachées réduit l'''overdraw'', mais elle ne fait pas de miracles. En pratique, le sur-dessinage ne concerne qu'une partie assez mineure des pixels de l'image, et un pixel est rarement écrit plus d'une dizaine de fois. Et les GPus modernes ont un ''pixel fillrate'' tellement démentiel qu'il n'est presque jamais un facteur limitant. Le ''pixel fillrate'' d'un GPU dépend de plusieurs choses : le nombre de ROPs, leur fréquence d'horloge exprimée en MHz/GHz, la bande passante mémoire, et bien d'autres. En théorie, la bande passante mémoire n'est pas un point limitant, les concepteurs du GPU prévoient une mémoire suffisamment rapide pour qu'elle puisse encaisser le ''pixel fillrate'' maximal, tout en ayant encore de la marge pour lire des textures et la géométrie. En clair, le ''pixel fillrate'' est surtout dépendant des ROPs, de leur nombre, de leur vitesse, de leur implémentation. Le ''pixel fillrate'' du GPU est difficile à calculer, mais l'approximation la plus utilisée est la suivante. Elle part du principe qu'un ROP peut écrire un pixel par cycle d'horloge. Ce n'est pas forcément le cas, tout dépend de l'implémentation des ROPs. Certains GPU performants ont des ROPs capables d'écrire des blocs de 8*8 pixels d'un seul coup en mémoire vidéo, alors que d'anciens GPU font avec des ROPs limités, seulement capables d'écrire un pixel tout les 10 cycles d'horloge. Toujours est-il qu'avec cette hypothèse, le ''pixel fillrate'' est égal au nombre de ROPs, multiplié par leur fréquence d'horloge. Je précise "leur" fréquence d'horloge, car il est possible de faire fonctionner l'unité de T&L, les ROPs, les unités de texture et le rastériseur à des fréquences différentes. C'est parfaitement possible, le cout en performance est parfois assez faible, mais le gain en consommation d'énergie est souvent important. Et justement, il a existé des GPU sur lesquels les ROPs avaient une fréquence inférieure à celle du reste du GPU. Dans ce cas, c'est la fréquence des ROPs qui est importante. Mais rassurez-vous : sur la majorité des GPUs actuels, les ROPs vont à la même fréquence que le reste du GPU. ===Le ''texture fillrate'' : la performance des unités de texture=== Le '''''texture fillrate''''' est l'équivalent du ''pixel fillrate'', mais pour les textures. Pour rappel, une texture est avant tout une image, composée de pixels. Pour éviter toute confusion, ces pixels de textures sont appelés ''des texels''. Le ''texture fillrate'' est le nombre de texels que la carte graphique peut plaquer par seconde, dans le meilleur des cas. Il est mesuré en mégatexels par secondes, voire en gigatexels par secondes. L'interprétation de ce chiffre dépend de si on le mesure en entrée ou en sortie des unités de texture. En effet, les unités de texture intègrent des fonctionnalités de filtrage de texture, qui lissent les textures. Ces techniques lisent plusieurs texels et les mélangent pour fournir le texel final, celui envoyé aux unités de ''shader'' ou aux ROPs. La coutume est de le mesurer en sortie des unités de texture. Le nombre en entrée dépend grandement de la bande passante mémoire et du filtrage de texture utilisé, pas celui en sortie. Le ''texture fillrate'' en sortie est le nombre maximal d'opérations de placage de texture par seconde. Là encore, on peut l'estimer en multipliant le nombre d'unités de texture par leur fréquence. Il s'agit évidemment d'une approximation assez peu fiable, car les unités de texture peuvent mettre plusieurs cycles pour plaquer une texture, les filtrer, etc. Le ''texture fillrate'' est bien plus important que le ''pixel fillrate'', surtout pour les GPU modernes. Un point important est que le ''texture fillrate'' a longtemps été égal au ''pixel fillrate''. C'était le cas avant la Geforce 2 de NVIDIA. Les cartes graphiques avaient autant d'unités de texture que de ROP, et les deux fonctionnaient à la même fréquence. Les deux ont commencés à diverger quand le multi-texturing est arrivé, avec la Geforce 2, justement. Le nombre d'unités de texture a doublé comparé aux ROPs, ce qui fait que le ''texture fillrate'' est rapidement devenu le double du ''pixel fillrate''. Sur les GPU modernes, le ''texture fillrate'' est le triple, quadruple, voire octuple du ''pixel fillrate''. ===La performance de l'unité géométrique=== Pour l'unité géométrique, l'équivalent au ''fillrate'' est le '''''polygon throughput'''''. C'est nombre de sommets que l'unité géométrique peut traiter par seconde, exprimé en ''méga-sommets par secondes'', en millions de sommets par seconde. Il dépend de la fréquence et du nombre d'unités géométriques, mais n'est pas exactement le produit des deux. Il varie beaucoup d'une carte graphique à l'autre, mais une approximation souvent utilisée prend le quart du produit fréquence * nombre d'unités géométriques. Il faut noter que cette mesure de performance a survécu à l'arrivée des shaders. Les GPU anciens, avant DirectX 10, avaient des processeurs séparés pour les ''vertex shaders'' et les ''pixel shaders''. Mais les calculs géométriques restaient séparés des autres calculs, ils avaient des unités géométriques dédiées. Quand les processeurs de shaders dit unifiés sont arrivés, la séparation entre géométrie et autres calculs a cédé et cet indicateur a simplement disparu. ===Les autres circuits=== Pour les autres circuits, il n'y a malheureusement pas d'indicateur de performance clair et net comme peut l'être le ''fillrate''. La raison à cela se comprend assez bien quand on regarde comment se calcule le ''fillrate''. C'est juste le produit de la fréquence et d'un nombre d'unités, en l’occurrence des unités de texture ou des ROPs. Le produit signifie que ces unités travaillent en parallèle et qu'elles peuvent chacune traiter un pixel/texel indépendamment des autres. Par contre, sur les anciens GPUs de l'époque, le rastériseur et l'unité géométrique sont un seul et unique circuit. Le nombre d'unité est donc égal à 1, et il ne nous reste plus que la fréquence. <noinclude> {{NavChapitre | book=Les cartes graphiques | prev=Le rendu d'une scène 3D : concepts de base | prevText=Le rendu d'une scène 3D : concepts de base | next=L'évolution vers la programmabilité : les GPUs | nextText=L'évolution vers la programmabilité : les GPUs }}{{autocat}} </noinclude> ofru7yueag2und5dd6julcfswztiq2q Dictionnaire de philosophie/Aristote 0 83057 765143 765113 2026-04-26T19:28:56Z PandaMystique 119061 /* La philosophie de la nature */ 765143 wikitext text/x-wiki {{DicoPhilo|Aristote}} Aristote (en grec ancien Ἀριστοτέλης, ''Aristotélēs'', 384-322 av. J.-C.) est un philosophe grec dont l'œuvre conservée couvre la logique, la philosophie de la nature, la biologie, la métaphysique, l'éthique, la politique, la rhétorique et la poétique. Né à Stagire en Chalcidique, il fut élève de Platon à l'Académie d'Athènes pendant une vingtaine d'années, puis précepteur d'Alexandre de Macédoine, avant de fonder à Athènes sa propre école, le Lycée. La transmission de ses écrits, complexe et partiellement lacunaire, a fortement conditionné l'ordre dans lequel ils nous sont parvenus ainsi que les modalités de leur interprétation : les dialogues exotériques publiés de son vivant ne subsistent qu'à l'état de fragments, tandis que les traités acroamatiques que nous lisons aujourd'hui ont été édités, organisés et titrés au Ier siècle av. J.-C. par Andronicos de Rhodes<ref>Sur l'histoire de la transmission, voir Pierre Pellegrin, ''Dictionnaire Aristote'', Paris, Ellipses, 2007, p. 5-7 ; Annick Jaulin, ''Aristote. La métaphysique'', Paris, PUF, 1999, Introduction.</ref>. L'œuvre conservée a exercé une influence durable sur la philosophie ancienne tardive, sur les traditions arabe et latine médiévales, ainsi que sur plusieurs renaissances modernes de l'aristotélisme. Sa réception se fait selon des angles variables : les commentateurs néoplatoniciens en font une propédeutique au platonisme, les théologiens médiévaux y voient « le philosophe » par excellence (''Philosophus''), tandis que la science moderne se construit en partie en réaction à la physique péripatéticienne, sans pour autant rompre toujours avec elle de manière unilatérale<ref>Sur la pluralité des « Aristote » selon les époques, voir Daniel Larose, ''Aristote de A à Z'', Paris, PUF, « Que sais-je ? », 2021, Introduction.</ref>. Au {{XXe siècle}}, le projet « génétique » de Werner Jaeger – qui distinguait trois périodes (platonicienne, critique, empirique) dans la pensée du Stagirite – a été largement abandonné par la recherche universitaire, qui aborde aujourd'hui le corpus comme un ensemble cohérent sans être pour autant systématique<ref>Daniel Larose, ''Aristote de A à Z'', op. cit. ; voir aussi Pellegrin, ''Dictionnaire Aristote'', p. 6-8.</ref>. Le présent article expose successivement la vie et le contexte historique d'Aristote, la constitution et la transmission de son œuvre, son rapport à Platon, puis ses doctrines en logique (l{{'}}''Organon''), philosophie de la nature, biologie, métaphysique et philosophie pratique (éthique, politique, rhétorique et poétique). Une dernière section traite de la réception et de la postérité de la pensée aristotélicienne. == Vie et contexte historique == === Origines familiales et contexte macédonien === Aristote naquit en 384 av. J.-C. à Stagire<ref>Stagire, l'actuelle Stavro, était une colonie grecque de la Chalcidique de Thrace, située sur la côte septentrionale de la mer Égée, à proximité de la Macédoine. Voir Werner Jaeger, ''Aristote. Fondements pour une histoire de son évolution'', trad. fr. O. Sedeyn, Paris, L'Éclat, 1997 (1923), p. 103-104.</ref>, petite cité de la Chalcidique en Macédoine, d'où son surnom de Stagirite<ref>Ce patronyme sera employé tout au long de l'Antiquité pour le désigner. Cf. Ingemar Düring, ''Aristotle in the Ancient Biographical Tradition'', Göteborg, Almqvist & Wiksell, 1957, p. 253-257.</ref>. Son père, Nicomaque, était médecin et appartenait à la corporation des Asclépiadés, dont les membres prétendaient descendre du dieu de la médecine, Asclépios<ref>Les Asclépiadés formaient une famille sacerdotale de médecins héritiers d'une tradition hippocratique. Voir Diogène Laërce, ''Vies et doctrines des philosophes illustres'', V, 1, trad. sous la dir. de M.-O. Goulet-Cazé, Paris, Le Livre de Poche, 1999, p. 573.</ref>. Nicomaque exerçait la fonction de médecin personnel et ami (''philos'') du roi Amyntas III de Macédoine<ref>Cette position à la cour macédonienne conférait à la famille d'Aristote un statut privilégié et des relations avec la maison royale. Voir Jean Brun, ''Aristote et le Lycée'', Paris, PUF, « Que sais-je ? », 1961, p. 7-8.</ref><ref>Diogène Laërce, ''Vies'', V, 1, op. cit., p. 573.</ref>. Sa mère, Phæstias (ou Phaestis), originaire de Chalcis en Eubée, était sage-femme<ref>Certaines sources antiques indiquent qu'elle descendait d'une famille chalcidienne de notables. Voir Ingemar Düring, ''Aristotle in the Ancient Biographical Tradition'', op. cit., p. 259.</ref>. La famille d'Aristote s'inscrivait ainsi dans un milieu cultivé, marqué à la fois par la pratique médicale rationnelle et par des ascendances ioniennes<ref>Selon Werner Jaeger, ces origines ioniennes pourraient avoir favorisé son goût pour l'investigation scientifique de la nature (''physis''), caractéristique des premiers Physiciens d'Ionie. Voir Werner Jaeger, ''Aristote'', op. cit., p. 105-106. Cette thèse, défendue par Jaeger dans le cadre de son interprétation génétique de la pensée d'Aristote, est aujourd'hui discutée par les historiens.</ref><ref>Ernest Barker, ''The Politics of Aristotle'', Oxford, Clarendon Press, 1946, Introduction, p. XI.</ref>. Aristote devint orphelin à un âge précoce, perdant son père alors qu'il était encore enfant<ref>Sa mère mourut également jeune, laissant Aristote orphelin de ses deux parents avant ses dix-sept ans. Voir Werner Jaeger, ''Aristote'', op. cit., p. 104.</ref>. Il fut dès lors élevé par Proxène d'Atarnée, un ami de sa famille, originaire d'Atarnée en Mysie<ref>Atarnée était une cité située en Asie Mineure, en Troade. Diogène Laërce, ''Vies'', V, 3, op. cit., p. 574.</ref>. En reconnaissance, Aristote adoptera plus tard Nicanor, le fils de Proxène, et lui destinera sa propre fille Pythias par son testament<ref>Le testament d'Aristote, conservé par Diogène Laërce (V, 11-16), témoigne de ces liens familiaux durables. Voir l'édition dans Pierre Pellegrin (dir.), ''Aristote. Œuvres complètes'', Paris, Flammarion, 2014, p. 2597-2600.</ref><ref>Ces nouvelles attaches familiales constitueront les premiers rapports qu'Aristote entretiendra avec la région d'Atarnée, où il rencontrera plus tard Hermias, le futur tyran du lieu. Voir Ingemar Düring, ''Aristotle in the Ancient Biographical Tradition'', op. cit., p. 272-277.</ref>. === Formation à l'Académie de Platon (367-347 av. J.-C.) === À l'âge de dix-sept ans, en 367-366 av. J.-C., Aristote se rend à Athènes, qui demeure alors, malgré son déclin politique et économique après les Guerres du Péloponnèse, l'un des principaux foyers intellectuels du monde grec<ref>Sous l'archontat de Polyzélos (selon Denys d'Halicarnasse) ou de Nausigénès (selon d'autres sources). Apollodore d'Athènes, cité par Diogène Laërce, V, 9, op. cit., p. 577.</ref>. Il entre à l'Académie de Platon, école philosophique fondée vers 387 av. J.-C.<ref>L'Académie tirait son nom du gymnase d'Académos, situé dans un faubourg d'Athènes. Voir Harold Cherniss, ''The Riddle of the Early Academy'', Berkeley, University of California Press, 1945, p. 1-72.</ref>. Cette institution dispensait un enseignement encyclopédique en mathématiques, astronomie, musique, dialectique, politique et philosophie<ref>Léon Robin, ''Aristote'', Paris, PUF, 1944, p. 3.</ref>. Aristote y demeura vingt ans, jusqu'à la mort de Platon en 348-347 av. J.-C.<ref>Apollodore d'Athènes, cité par Diogène Laërce (V, 9-10), fournit cette chronologie. Voir Diogène Laërce, ''Vies'', op. cit., p. 577-578.</ref>. La tradition antique rapporte que Platon l'aurait surnommé le Liseur (''anagnôstês'') ou l'Intelligence de l'école (''ho nous tês scholês'')<ref>Ces surnoms, qui pourraient attester la reconnaissance par le Maître des dons de son élève, sont rapportés par les sources biographiques tardives. Voir Ingemar Düring, ''Aristotle in the Ancient Biographical Tradition'', op. cit., p. 325-327.</ref><ref>''Vita Marciana'', dans Valentin Rose, ''Aristoteles Pseudepigraphus'', Leipzig, Teubner, 1863, p. 428 ; Jean Philopon, dans Proclus, ''De aeternitate mundi'', VI, 27, éd. Hugo Rabe, Leipzig, Teubner, 1899, p. 145.</ref>. La tradition d'une rupture violente entre Aristote et Platon, attestée par certaines sources tardives, est aujourd'hui considérée par la critique moderne comme issue probablement des polémiques ultérieures entre épicuriens et péripatéticiens<ref>Les récits de Diogène Laërce évoquant une rupture violente sont jugés peu fiables par la plupart des biographes modernes. Voir Werner Jaeger, ''Aristote'', op. cit., p. 113-128.</ref>. Plusieurs éléments suggèrent au contraire des liens durables entre les deux philosophes : Aristote composa une élégie en l'honneur de son ami Eudème de Chypre, où il évoque Platon comme un maître<ref>Fragment 623 (Rose), conservé par Olympiodore. Voir Valentin Rose, ''Aristotelis qui ferebantur librorum fragmenta'', Leipzig, Teubner, 1886, p. 42-43.</ref>. Dans l{{'}}''Éthique à Nicomaque'', Aristote écrit : « Une recherche de ce genre est rendue difficile du fait que ce sont des amis qui ont introduit la doctrine des Idées. Mais on admettra peut-être qu'il est préférable, et c'est aussi pour nous une obligation, si nous voulons du moins sauvegarder la vérité, de sacrifier même nos sentiments personnels, surtout quand on est philosophe : vérité et amitié nous sont chères l'une et l'autre, mais c'est pour nous un devoir sacré d'accorder la préférence à la vérité »<ref>Aristote, ''Éthique à Nicomaque'', I, 4, 1096 a 12-17, trad. J. Tricot, Paris, Vrin, 1959, p. 44.</ref><ref>Cette phrase a été condensée en latin par l'adage ''Amicus Plato, sed magis amica veritas'' : « ami de Platon, mais plus encore ami de la vérité ». Voir Ammonius, ''In Categorias'', éd. Busse, Berlin, Reimer, 1895, p. 79.</ref>. Aristote participa à l'enseignement de l'Académie. On lui attribue notamment la charge du cours de rhétorique, qu'il aurait inauguré, selon certains témoignages, par cette formule : « Il serait honteux de se taire et de laisser parler Isocrate »<ref>Cette boutade visait Isocrate, le rival de Platon en matière d'enseignement rhétorique. Voir Werner Jaeger, ''Aristote'', op. cit., p. 180-182.</ref><ref>Philodème, ''Volumina rhetorica'', II, 36, 3-5, éd. Siegfried Sudhaus, Leipzig, Teubner, 1892-1896, vol. II, p. 50 ; Cicéron, ''De Oratore'', III, 35, 141 ; Quintilien, ''Institutio oratoria'', III, 1, 14.</ref>. Cette période fut également celle de ses premières publications, sous forme de dialogues à la manière platonicienne, dont la plupart sont aujourd'hui perdus<ref>Cicéron loua plus tard la qualité littéraire de ces dialogues, parlant d'un ''flumen orationis aureum'' : « un fleuve d'or de paroles ». Cicéron, ''Academica priora'', II, 38, 119.</ref>. === Voyages et maturation (348-335 av. J.-C.) === À la mort de Platon en 347, c'est le neveu du Maître, Speusippe, qui prend la direction de l'Académie. Aristote quitte alors Athènes — selon certaines hypothèses parce qu'il aurait espéré la succession, selon d'autres en raison du climat politique antimacédonien<ref>Voir Pierre Pellegrin, ''Dictionnaire Aristote'', op. cit., p. 5.</ref>. Il se rend à Atarnée et Assos, en Troade (Asie Mineure), auprès de son ami Hermias, ancien condisciple de l'Académie devenu tyran d'Atarnée<ref>Hermias avait été esclave, puis affranchi, avant de devenir maître d'Atarnée et d'Assos. Voir Ingemar Düring, ''Aristotle in the Ancient Biographical Tradition'', op. cit., p. 272-277.</ref>. Aristote y séjourne trois ans (347-345), accompagné probablement de Xénocrate<ref>Strabon, ''Géographie'', XIII, 1, 57, trad. F. Lasserre, Paris, Les Belles Lettres, 1981, p. 84-85.</ref>. Il y poursuit ses recherches philosophiques et biologiques<ref>C'est durant ce séjour qu'Aristote aurait entrepris des observations sur la faune marine de la côte d'Asie Mineure, dont témoignent l{{'}}''Histoire des Animaux'' et d'autres traités zoologiques. Voir David M. Balme, « The Place of Biology in Aristotle's Philosophy », dans Allan Gotthelf & James G. Lennox (éd.), ''Philosophical Issues in Aristotle's Biology'', Cambridge, Cambridge University Press, 1987, p. 9-20.</ref>. Hermias, pris dans les luttes entre la Macédoine et la Perse, fut capturé en 341 av. J.-C. par trahison sur ordre d'Artaxerxès III, le roi de Perse, torturé et exécuté<ref>Selon les sources antiques, Hermias refusa, malgré les tortures, de trahir ses liens avec Philippe II de Macédoine. Voir Werner Jaeger, ''Aristote'', op. cit., p. 138-142.</ref><ref>Diodore de Sicile, ''Bibliothèque historique'', XVI, 52, trad. P. Goukowsky, Paris, Les Belles Lettres, 1976, p. 164-165.</ref>. Aristote composa un Hymne à la Vertu en son honneur et fit ériger une statue à Delphes portant une épigramme à sa mémoire<ref>Fragments 624 et 625 (Rose), conservés par Diogène Laërce (V, 7-8) et Athénée (''Deipnosophistes'', XV, 696 a-c). Voir Valentin Rose, ''Aristotelis qui ferebantur librorum fragmenta'', op. cit., p. 43-44.</ref>. Il épousa également Pythias, nièce ou fille adoptive d'Hermias, dont il aura une fille du même nom<ref>Dans son testament, Aristote prescrit que ses cendres soient mêlées à celles de Pythias. Diogène Laërce, ''Vies'', V, 16, op. cit., p. 582.</ref>. Après la mort d'Hermias, Aristote quitte Assos pour Mytilène, sur l'île de Lesbos, en 345-344 av. J.-C. Il y poursuit pendant deux à trois ans ses observations biologiques et zoologiques, notamment sur les lagunes de Pyrrha<ref>De nombreuses observations précises de l{{'}}''Histoire des Animaux'' se rapportent à la faune de Lesbos. Voir Pierre Louis, « Introduction », dans Aristote, ''Histoire des Animaux'', tome I, Paris, Les Belles Lettres, 1964, p. XIV-XVIII.</ref>. === Précepteur d'Alexandre le Grand (343-336 av. J.-C.) === En 343-342 av. J.-C., Philippe II de Macédoine, qui avait connu Aristote dans sa jeunesse grâce à Nicomaque, appelle le philosophe à la cour de Pella pour devenir le précepteur de son fils, le jeune prince Alexandre, alors âgé de treize ans<ref>Voir Werner Jaeger, ''Aristote'', op. cit., p. 145-148.</ref><ref>Plutarque, ''Vie d'Alexandre'', 7-8, trad. R. Flacelière & É. Chambry, Paris, Les Belles Lettres, 1975, p. 33-34.</ref>. Aristote enseigne au prince pendant deux ou trois ans, probablement à Miéza<ref>Miéza était située à l'ouest de Pella, dans un environnement champêtre propice aux études. Voir Jean Brun, ''Aristote et le Lycée'', op. cit., p. 15-16.</ref>. L'enseignement donné à Alexandre portait essentiellement sur la politique, l'éthique, la poésie et les lettres grecques<ref>Selon Plutarque, Aristote aurait fait connaître à Alexandre une édition annotée de l{{'}}''Iliade''. Plutarque, ''Vie d'Alexandre'', 8, op. cit., p. 34.</ref><ref>Plutarque rapporte également un mot d'Alexandre selon lequel il aimait Aristote autant que son père : « si je dois la vie à l'un, je dois à l'autre de savoir bien vivre ». Plutarque, ''Vie d'Alexandre'', 8, op. cit., p. 34.</ref>. Aristote composa peut-être à cette époque un traité ''Sur la Royauté'' (''Peri basileias''), aujourd'hui perdu<ref>Voir Paul Moraux, ''Les listes anciennes des ouvrages d'Aristote'', Louvain, Éditions universitaires, 1951, p. 157-158.</ref>. Les relations entre le maître et l'élève demeurent difficiles à reconstituer avec certitude. Alors qu'Aristote, dans ses ''Politiques'', prône une distinction entre Grecs et Barbares<ref>Aristote considère, dans certains passages, que les Barbares sont naturellement faits pour être gouvernés par les Grecs. Aristote, ''Politique'', I, 2, 1252 b 5-9, trad. J. Aubonnet, Paris, Les Belles Lettres, 1960, p. 10-11.</ref>, Alexandre adopta une politique d'intégration des peuples conquis et de fusion entre Grecs et Orientaux<ref>Sur ces divergences, voir Werner Jaeger, ''Aristote'', op. cit., p. 148-152.</ref>. En 335 av. J.-C., lors du départ d'Alexandre pour la conquête de l'Asie, Aristote propose son neveu Callisthène pour l'accompagner comme conseiller et historiographe<ref>Voir Felix Jacoby, ''Die Fragmente der griechischen Historiker'', Berlin, Weidmann, 1923-1958, 124 F 1.</ref>. Callisthène, refusant d'adopter la proskynèse exigée par Alexandre, encourut sa disgrâce et fut exécuté vers 327 av. J.-C.<ref>Diogène Laërce (V, 5) et Plutarque (''Vie d'Alexandre'', 55) rapportent cette fin. Voir Diogène Laërce, ''Vies'', op. cit., p. 575-576 ; Plutarque, ''Vie d'Alexandre'', op. cit., p. 81-83.</ref>. === Fondation du Lycée et enseignement à Athènes (335-323 av. J.-C.) === En 335-334 av. J.-C., après la défaite athénienne face à la Macédoine à Chéronée (338 av. J.-C.), Aristote retourne à Athènes. Il y fonde sa propre école, le Lycée (''Lykeion''), nommé d'après le temple d'Apollon Lycien situé à proximité<ref>Le Lycée était un gymnase entouré de jardins et de promenoirs couverts (''peripatoi''), d'où le nom d'« école péripatéticienne » donné aux disciples d'Aristote. Voir Jean Brun, ''Aristote et le Lycée'', op. cit., p. 21-24.</ref><ref>Le site archéologique du Lycée fut découvert fortuitement en 1996 lors de travaux au centre d'Athènes. Voir Effi Lygouri, « The Lyceum of Aristotle in Athens », dans ''Acta Musei Nationalis Pragae. Series A – Historia'', 60, 1-2, 2006, p. 47-52.</ref>. Aristote y enseigne pendant douze ou treize ans, de 335 à 323 av. J.-C., période durant laquelle il rédige la plupart des traités systématiques (''pragmateiai'') qui nous sont parvenus<ref>Ces traités, dits « acroamatiques » ou « ésotériques », étaient destinés à l'enseignement interne, par opposition aux dialogues « exotériques » écrits pour un large public. Voir Paul Moraux, ''Les listes anciennes des ouvrages d'Aristote'', op. cit., p. 3-85.</ref>. Selon Aulu-Gelle, son enseignement comportait deux modalités : le matin, des leçons approfondies sur la philosophie, la physique et la métaphysique, réservées aux disciples avancés ; l'après-midi, des cours plus accessibles de rhétorique et de dialectique, ouverts à un public plus large<ref>Aulu-Gelle, ''Nuits attiques'', XX, 5, trad. R. Marache, Paris, Les Belles Lettres, 1978, tome IV, p. 185-186.</ref>. Aristote professait, selon la tradition, en se promenant (''peripatein'') avec ses disciples dans les allées du gymnase, d'où le nom d'école péripatéticienne donné à son école<ref>Diogène Laërce, ''Vies'', V, 2, op. cit., p. 574.</ref>. Il constitua au Lycée une bibliothèque et des collections d'histoire naturelle (animaux, plantes, minéraux, cartes géographiques)<ref>Selon Pline l'Ancien, Alexandre aurait aidé son ancien maître en lui envoyant d'Asie des spécimens zoologiques et botaniques. Pline l'Ancien, ''Histoire naturelle'', VIII, 17, trad. A. Ernout, Paris, Les Belles Lettres, 1952, p. 23. Cette tradition est cependant tardive et son authenticité est discutée.</ref>. Il entreprit également avec ses collaborateurs la rédaction des 158 Constitutions (''Politeiai'') des cités grecques et barbares, dont seule la ''Constitution d'Athènes'' nous est parvenue<ref>Cette œuvre fut probablement un travail collectif sous la direction d'Aristote. Voir Mortimer Chambers, « Aristotle's Forms of Democracy », dans ''Transactions and Proceedings of the American Philological Association'', 92, 1961, p. 20-36.</ref>. === Exil et mort (323-322 av. J.-C.) === À la mort d'Alexandre le Grand, survenue à Babylone en juin 323 av. J.-C., une réaction antimacédonienne éclate à Athènes<ref>Voir Werner Jaeger, ''Aristote'', op. cit., p. 387-390.</ref>. Aristote, en tant que métèque proche de la Macédoine, se trouve menacé<ref>En tant que métèque, Aristote n'avait jamais eu le droit de cité athénien. Voir Jean Brun, ''Aristote et le Lycée'', op. cit., p. 32-33.</ref>. Un certain Démophile, peut-être prêtre d'Éleusis, l'accuse d'impiété (''asebeia''), lui reprochant d'avoir composé l'Hymne à la Vertu en l'honneur d'Hermias, honneur normalement réservé aux dieux<ref>Cette accusation rappelle évidemment le procès de Socrate en 399 av. J.-C. Voir Ingemar Düring, ''Aristotle in the Ancient Biographical Tradition'', op. cit., p. 346-350.</ref><ref>Diogène Laërce, ''Vies'', V, 5-6, op. cit., p. 575-576.</ref>. Sans attendre le jugement, Aristote quitte Athènes et se réfugie à Chalcis, en Eubée, ville natale de sa mère<ref>Selon Aulu-Gelle, Aristote aurait déclaré vouloir « empêcher les Athéniens de commettre un second crime contre la philosophie », allusion au procès de Socrate. Aulu-Gelle, ''Nuits attiques'', III, 3, 10, op. cit., tome I, p. 151.</ref>. Il confie la direction du Lycée à Théophraste d'Érèse<ref>Théophraste dirigera le Lycée pendant trente-cinq ans, jusqu'en 288-287 av. J.-C. Voir Diogène Laërce, ''Vies'', V, 36-57, op. cit., p. 590-601.</ref>. Aristote meurt à Chalcis en 322 av. J.-C., à l'âge de soixante-deux ans, probablement des suites d'une maladie d'estomac<ref>La légende d'un suicide par absorption de ciguë, rapportée par certaines sources tardives, n'est pas retenue par les historiens modernes. Voir Ingemar Düring, ''Aristotle in the Ancient Biographical Tradition'', op. cit., p. 350-355.</ref><ref>Son testament, conservé par Diogène Laërce (V, 11-16), prescrit notamment que ses cendres soient mêlées à celles de sa première épouse Pythias. Voir Diogène Laërce, ''Vies'', op. cit., p. 579-582.</ref>. Ses cendres furent inhumées à Stagire<ref>Voir Ingemar Düring, ''Aristotle in the Ancient Biographical Tradition'', op. cit., p. 355-358.</ref>. Aristote laissait deux enfants : Pythias, née de son premier mariage, destinée à épouser Nicanor, le fils adoptif du philosophe, et Nicomaque, né de son second mariage avec Herpyllis d'Assos<ref>C'est à ce dernier que fut dédiée l'''Éthique à Nicomaque'' — sans qu'il soit certain que ce titre traduise une dédicace personnelle plutôt qu'un choix éditorial postérieur. Voir Jean Brun, ''Aristote et le Lycée'', op. cit., p. 35-36.</ref><ref>Dans son testament, Aristote témoigne d'une grande reconnaissance envers Herpyllis. Diogène Laërce, ''Vies'', V, 12-13, op. cit., p. 580.</ref>. == L'œuvre aristotélicienne : constitution et transmission == La constitution et la transmission de l'œuvre d'Aristote représentent l'un des dossiers les plus complexes de l'histoire de la philosophie antique. L'accès à la pensée du Stagirite est tributaire d'un processus historique long, marqué par des péripéties qui ont conditionné la forme sous laquelle nous lisons aujourd'hui ses écrits<ref>Pour une synthèse récente, voir Pierre Pellegrin, ''Dictionnaire Aristote'', op. cit., p. 5-7 ; Annick Jaulin, ''Aristote. La métaphysique'', op. cit., Introduction.</ref>. === La double nature de l'œuvre aristotélicienne === L'œuvre d'Aristote se composait à l'origine de deux types d'écrits distincts. D'une part, les écrits exotériques (ἐξωτερικοὶ λόγοι), destinés à un public extérieur à l'école et composés sous forme de dialogues à la manière platonicienne entre 360 et 345 av. J.-C.<ref>Voir Diogène Laërce, ''Vies et doctrines des philosophes illustres'', V, 22-27.</ref>. Ces dialogues de jeunesse, dont Cicéron louait les qualités littéraires en qualifiant le style d'Aristote de « fleuve d'or » (''flumen aureum'')<ref>Cicéron, ''Academica'', II, 38, 119.</ref>, comprenaient notamment le ''Gryllos ou De la rhétorique'', l'''Eudème ou De l'âme'' sur l'immortalité de l'âme, le ''Protreptique'' et le traité ''Sur la philosophie''<ref>Fragments rassemblés dans V. Rose, ''Aristotelis qui ferebantur librorum fragmenta'', Leipzig, Teubner, 1886.</ref>. Ces œuvres ne nous sont parvenues que sous forme de fragments. D'autre part, les écrits acroamatiques ou ésotériques (ἀκροαματικοί, ἐσωτερικοί), réservés aux disciples du Lycée et destinés à un usage interne à l'école<ref>Sur cette distinction, voir Aulu-Gelle, ''Nuits attiques'', XX, 5 ; Plutarque, ''Vie d'Alexandre'', 7.</ref>. Ces textes constituent l'essentiel du corpus aristotélicien tel que nous le connaissons aujourd'hui. Ils prennent vraisemblablement la forme de notes de cours, de résumés ou de mémoires de recherche élaborés par Aristote et ses collaborateurs. Selon Pierre Pellegrin, on pourrait considérer en termes modernes que « le texte aristotélicien que nous lisons aujourd'hui est plutôt le compte-rendu d'un séminaire donné par Aristote, qu'un ouvrage rédigé en bonne et due forme »<ref>Pierre Pellegrin, ''Dictionnaire Aristote'', op. cit., p. 6.</ref>. === Le sort de la bibliothèque d'Aristote === L'histoire de la transmission est intimement liée au destin de la bibliothèque personnelle d'Aristote. À sa mort en 322 av. J.-C., Aristote légua sa bibliothèque à son successeur à la tête du Lycée, Théophraste d'Érèse<ref>Diogène Laërce, ''Vies'', V, 51-52.</ref>. Selon le récit de Strabon, à la mort de Théophraste vers 288 av. J.-C., la bibliothèque fut léguée à Nélée de Scepsis, fils de Coriscos et disciple d'Aristote et de Théophraste<ref>Strabon, ''Géographie'', XIII, 1, 54 (608-609 C) ; voir aussi Plutarque, ''Sylla'', 26, 1-3.</ref>. Nélée, en quittant Athènes pour retourner à Scepsis en Troade, emporta avec lui les manuscrits autographes d'Aristote et de Théophraste. Ses descendants, peu instruits selon Strabon, auraient caché ces livres dans une cave pour éviter qu'ils ne soient confisqués par les rois Attalides de Pergame<ref>Strabon, ''Géographie'', XIII, 1, 54.</ref>. Les ouvrages auraient ainsi été enfouis pendant près de deux siècles. Ce récit de Strabon, longtemps accepté tel quel, est aujourd'hui partiellement contesté : la recherche moderne souligne qu'il ne faut pas conclure de la dispersion d'une partie des manuscrits autographes que les œuvres d'Aristote auraient été totalement inaccessibles avant Andronicos<ref>Voir P. Moraux, ''Der Aristotelismus bei den Griechen'', vol. I, Berlin-New York, De Gruyter, 1973, p. 3-31. La critique moderne souligne que les philosophes hellénistiques (stoïciens, épicuriens) connaissaient et discutaient certaines thèses aristotéliciennes, ce qui suppose un accès au moins partiel aux écrits du Stagirite.</ref>. Ce n'est qu'au début du Ier siècle av. J.-C. que ces textes furent redécouverts et achetés par Apellicon de Téos, un riche bibliophile athénien<ref>Athénée, ''Deipnosophistes'', V, 214d = Posidonios, FGrHist 87 F 36.</ref>. Apellicon entreprit de restaurer les manuscrits endommagés, mais aurait, selon Strabon, commis de nombreuses erreurs en comblant les lacunes<ref>Strabon, ''Géographie'', XIII, 1, 54.</ref>. === Le transfert à Rome et l'édition d'Andronicos === En 86 av. J.-C., lors de la prise d'Athènes, le général Sylla s'empara de la bibliothèque d'Apellicon et la fit transporter à Rome<ref>Plutarque, ''Sylla'', 26, 2 ; Strabon, ''Géographie'', XIII, 1, 54.</ref>. À Rome, ces manuscrits furent confiés au grammairien Tyrannion d'Amisos<ref>Strabon, ''Géographie'', XIII, 1, 54 ; Cicéron, ''Lettres à Atticus'', IV, 10, 1.</ref>. C'est finalement Andronicos de Rhodes, onzième scholarque du Lycée et philosophe péripatéticien actif vers 60 av. J.-C., qui établit la première édition systématique des œuvres d'Aristote et de Théophraste<ref>Porphyre, ''Vie de Plotin'', 24 ; voir aussi David l'Arménien, ''Prolégomènes'', CAG XVIII, 2, p. 32-33.</ref>. Andronicos ne se contenta pas de publier les textes : il les organisa selon un ordre thématique probablement différent de celui voulu par Aristote, en regroupant les traités par affinité de contenu. La division traditionnelle du corpus en écrits logiques (réunis plus tard sous le titre d'Organon), physiques, biologiques, métaphysiques, éthiques et politiques en résulte<ref>Voir Simplicius, ''In Categorias'', CAG VIII, p. 4, 28-5, 4 ; Alexandre d'Aphrodise, ''In Metaphysica'', CAG I, p. 171, 5-8.</ref>. Comme le souligne Pellegrin, Andronicos « a uni des passages qui étaient originairement disjoints, gommé des contradictions, ménagé des transitions », et il a probablement « introduit dans le texte des matériaux issus de discussions et de critiques » faites en marge des cours du Lycée<ref>Pierre Pellegrin, ''Dictionnaire Aristote'', op. cit., p. 6.</ref>. C'est également à Andronicos que l'on doit le titre de Métaphysique (τὰ μετὰ τὰ φυσικά), qui signifie littéralement « ce qui vient après les [livres] physiques »<ref>Voir ''Métaphysique'', éd. W.D. Ross, Oxford, Clarendon Press, 1924, vol. I, p. XXXII-XLIII ; Annick Jaulin, ''Aristote. La métaphysique'', op. cit., Introduction.</ref>. Aristote lui-même n'utilisait jamais ce terme, préférant parler de « philosophie première » (πρώτη φιλοσοφία) ou de « science théologique » (θεολογική)<ref>Aristote, ''Métaphysique'', E (VI), 1, 1026a19-23 ; K (XI), 7, 1064b1-3.</ref>. === Les conséquences pour notre accès au texte === Cette histoire éditoriale a eu des conséquences majeures pour notre connaissance de la pensée d'Aristote. La perte quasi totale des écrits exotériques nous prive d'un pan de son œuvre, celui qui était le mieux connu dans l'Antiquité<ref>Sur l'« autre » Aristote perdu, voir Pierre Pellegrin, ''Dictionnaire Aristote'', op. cit., p. 6.</ref>. L'état fragmentaire des manuscrits retrouvés et leur restauration introduisent des incertitudes textuelles. L'organisation systématique imposée par Andronicos, bien qu'elle ait permis la préservation et la diffusion de l'œuvre, ne correspond probablement pas à l'ordre de composition ni à l'organisation originelle voulue par Aristote. La reconstruction d'une chronologie relative des œuvres pose des difficultés méthodologiques. Werner Jaeger, dans son ouvrage ''Aristoteles. Grundlegung einer Geschichte seiner Entwicklung'' (1923), a proposé de dater les textes selon leur plus ou moins grande proximité avec le platonisme<ref>Werner Jaeger, ''Aristote. Fondements pour une histoire de son évolution'', op. cit.</ref>. Cette « approche génétique » a été largement critiquée et n'est plus acceptée dans son ensemble : le « degré de platonicité » d'un texte est difficile à évaluer, et le corpus aristotélicien se prête mal aux examens stylométriques utilisés pour le corpus platonicien<ref>Pour une critique de l'approche génétique de Jaeger, voir Pierre Pellegrin, ''Dictionnaire Aristote'', op. cit., p. 6-8 ; Daniel Larose, ''Aristote de A à Z'', op. cit., Introduction. La recherche actuelle aborde l'œuvre comme un tout cohérent sans pour autant la juger systématique.</ref>. Le texte d'Aristote tel que nous le connaissons est ainsi le produit d'une longue chaîne de transmission, qui passe par les commentateurs néoplatoniciens (Alexandre d'Aphrodise, Simplicius, Philopon), la tradition arabe médiévale (Al-Fârâbî, Avicenne, Averroès), puis la redécouverte occidentale à partir du XIIIe siècle<ref>Pour l'histoire de la transmission médiévale, voir F.E. Peters, ''Aristoteles Arabus'', Leiden, Brill, 1968 ; L. Minio-Paluello, ''Opuscula. The Latin Aristotle'', Amsterdam, Hakkert, 1972.</ref>. == Aristote et Platon : continuité et rupture == La relation entre Aristote et Platon a fait l'objet d'interprétations divergentes dans l'histoire de la philosophie. Les commentateurs néoplatoniciens, de Porphyre à Simplicius, ont insisté sur l'harmonie fondamentale entre les deux philosophes<ref>Simplicius, ''In Categorias'', CAG VIII, éd. K. Kalbfleisch, Berlin, Reimer, 1907, p. 7, 23-32.</ref>. À l'inverse, une partie de la tradition occidentale, depuis la Renaissance, a mis l'accent sur leurs divergences. Coleridge résume cette opposition dans la formule : « Tout homme est né aristotélicien ou platonicien »<ref>Samuel Taylor Coleridge, ''Table Talk'', 2 juillet 1830, dans ''The Table Talk and Omniana of Samuel Taylor Coleridge'', Londres, Oxford University Press, 1917, p. 90.</ref>. La recherche contemporaine privilégie une approche plus nuancée, qui examine simultanément les points de continuité et de rupture. Aristote s'oppose à Platon sur des questions ontologiques fondamentales, tout en demeurant tributaire de l'héritage académicien. Comme il l'écrit lui-même : « Une recherche de ce genre est rendue difficile du fait que ce sont des amis qui ont introduit la doctrine des Idées. Mais on admettra peut-être qu'il est préférable, et c'est aussi pour nous une obligation, si nous voulons du moins sauvegarder la vérité, de sacrifier même nos sentiments personnels »<ref>Aristote, ''Éthique à Nicomaque'', I, 4, 1096a11-17, trad. J. Tricot, op. cit., p. 44.</ref>. === La critique aristotélicienne des Idées platoniciennes === ==== Le rejet de la séparation (''chôrismos'') ==== L'un des points de rupture entre Aristote et Platon concerne le statut ontologique des Idées ou Formes (''eidos''). Pour Platon, les Idées constituent un monde intelligible séparé (''chôristos'') du monde sensible. Elles sont immuables, éternelles, et possèdent une existence autonome et transcendante. Les choses sensibles ne sont que des copies imparfaites qui participent (''methexis'') aux Idées ou les imitent (''mimêsis'')<ref>Platon, ''Phédon'', 74a-75d, 100c-102a ; ''République'', VI, 509d-511e, trad. É. Chambry, Paris, Les Belles Lettres, 1932-1934.</ref>. Dans le ''Phédon'', Platon affirme ainsi que « rien d'autre ne rend belle une chose que la présence ou la communion (on ne sait comment elle se fait) avec le Beau en soi »<ref>Platon, ''Phédon'', 100d, trad. M. Dixsaut, Paris, Flammarion, 1991, p. 315.</ref>. Aristote refuse cette séparation des Idées. Dans la ''Métaphysique'', il consacre plusieurs chapitres à une critique systématique de la théorie platonicienne<ref>Aristote, ''Métaphysique'', A, 6, 987a29-988a17 ; A, 9, 990a34-993a10 ; M, 4-5, 1078b7-1080a11 ; N, 2, 1088b35-1090a2.</ref>. Ses objections portent sur trois points principaux<ref>Pour une analyse détaillée de ces critiques, voir Vasilis Politis, ''Routledge Philosophy GuideBook to Aristotle and the Metaphysics'', Londres-New York, Routledge, 2004, chap. 3-4.</ref>. Premièrement, l'inutilité explicative des Idées séparées. Aristote soutient que les Idées ne peuvent rendre compte ni de l'existence ni du devenir des choses sensibles : « Dire que les Idées sont des paradigmes et que les autres choses y participent, c'est prononcer des mots vides et faire des métaphores poétiques »<ref>Aristote, ''Métaphysique'', A, 9, 991a20-22, trad. J. Tricot, Paris, Vrin, 1953, t. I, p. 61.</ref>. Les Idées platoniciennes, étant immobiles et éternelles, ne peuvent expliquer le mouvement et le changement observés. Selon Aristote, Platon n'a pas identifié la cause efficiente (''archê tês kinêseôs'')<ref>Aristote, ''Métaphysique'', A, 9, 992a24-29.</ref>. Deuxièmement, l'argument du « Troisième Homme ». Aristote reprend ici une objection déjà formulée dans le ''Parménide'' de Platon lui-même<ref>Platon, ''Parménide'', 132a-b.</ref>. Si l'on postule une Idée séparée de l'Homme pour expliquer ce que plusieurs hommes ont en commun, alors on doit également postuler une troisième Idée pour expliquer ce que l'Idée de l'Homme et les hommes particuliers ont en commun, et ainsi à l'infini<ref>Aristote, ''Métaphysique'', A, 9, 990b15-17 ; Z, 13, 1039a2-3 ; ''De la Sophistique'', 22, 178b36-179a10.</ref>. Troisièmement, le dédoublement inutile du monde. Postuler un monde intelligible d'Idées séparées revient, selon Aristote, à « doubler les difficultés » sans les résoudre<ref>Aristote, ''Métaphysique'', A, 9, 990b1-4.</ref>. « Les Platoniciens font exister autant de substances séparées qu'il y a de choses naturelles, comme si quelqu'un, voulant compter des objets, estimait qu'il ne le pourrait pas quand ils sont en petit nombre, et croyait y parvenir en les multipliant »<ref>Aristote, ''Métaphysique'', A, 9, 990b4-8, trad. J. Tricot, op. cit., p. 60.</ref>. ==== L'hylémorphisme aristotélicien ==== À la théorie platonicienne de la participation, Aristote oppose la théorie hylémorphique. Selon cette conception, les êtres sensibles ne sont pas des copies imparfaites d'Idées transcendantes, mais des composés (''sunolon'') de matière (''hulê'') et de forme (''morphê'' ou ''eidos'')<ref>Aristote, ''Métaphysique'', Z, 3, 1029a1-7 ; H, 1-3, 1042a24-1043b23 ; ''Physique'', II, 1-2, 192b8-194a12.</ref>. La forme aristotélicienne n'est pas séparée des choses sensibles : elle est immanente à la matière qu'elle informe. La forme du cheval n'existe pas dans un monde intelligible séparé, mais uniquement dans les chevaux individuels concrets. La forme est ce qui fait qu'une chose est ce qu'elle est, son essence (''to ti ên einai'')<ref>Aristote, ''Métaphysique'', Z, 4-6, 1029b13-1031b14 ; Z, 7, 1032b1-2.</ref>. « Par forme, j'entends l'essence de chaque chose et sa substance première »<ref>Aristote, ''Métaphysique'', Z, 7, 1032b1-2, trad. J. Tricot, op. cit., t. I, p. 372.</ref>. Cette conception hylémorphique permet de répondre à plusieurs problèmes posés par la théorie platonicienne. Elle explique le devenir : la génération d'une substance est le passage de la puissance (''dunamis'') à l'acte (''energeia''), l'actualisation d'une forme dans une matière. Elle sauvegarde l'individualité des substances premières, c'est-à-dire des individus concrets (''tode ti'')<ref>Aristote, ''Catégories'', 5, 2a11-19 ; ''Métaphysique'', Z, 13, 1038b23-1039a3.</ref>. Elle unifie enfin forme et finalité : la forme est à la fois cause formelle et cause finale, ce vers quoi tend le développement naturel de la chose<ref>Aristote, ''Physique'', II, 1, 193b12-13 ; II, 7, 198a24-27.</ref>. === La critique de la participation et de l'imitation === Platon utilise principalement deux concepts pour expliquer la relation entre les choses sensibles et les Idées : la participation (''methexis'') et l'imitation (''mimêsis''). Aristote critique ces notions, qu'il juge métaphoriques et non explicatives<ref>Aristote, ''Métaphysique'', A, 9, 991a20-22 ; M, 5, 1079b24-26.</ref>. La notion de participation pose des problèmes conceptuels que Platon lui-même a identifiés dans le ''Parménide''<ref>Platon, ''Parménide'', 130e-134e.</ref>. Si l'Idée tout entière est présente en chaque chose qui y participe, alors l'Idée se trouve séparée d'elle-même. Mais si c'est seulement une partie de l'Idée qui est présente en chaque chose, alors l'Idée se divise<ref>Platon, ''Parménide'', 131a-e.</ref>. Selon Aristote, Platon n'a pas résolu ces difficultés : « Dire que les Idées sont des paradigmes et que les autres choses y participent, c'est ne rien dire, car participer, qu'est-ce que c'est ? »<ref>Aristote, ''Métaphysique'', A, 9, 991a20-21.</ref>. Pour Aristote, « ce que les Pythagoriciens appelaient "imitation", les Platoniciens l'ont appelé "participation", mais tous se bornent à changer le nom sans expliquer ce que peut bien être cette participation ou cette imitation des Idées »<ref>Aristote, ''Métaphysique'', A, 6, 987b10-13, trad. J. Tricot, op. cit., t. I, p. 49.</ref>. Pour Aristote, la ressemblance entre les individus d'une même espèce s'explique par la possession d'une même forme immanente, transmise dans la génération naturelle : « L'homme engendre l'homme » (''anthrôpos anthrôpon genna'')<ref>Aristote, ''Métaphysique'', Z, 7, 1032a25 ; Z, 8, 1033b32-1034a2 ; Λ, 3, 1070a8.</ref>. Trois facteurs interviennent dans la génération : la matière (fournie par la femelle, selon Aristote), la forme (transmise par le mâle) et la privation (l'absence initiale de la forme dans la matière)<ref>Aristote, ''Physique'', I, 7, 190b10-191a7 ; ''De la Génération des Animaux'', I, 20-21, 729a9-730a32. Cette doctrine de la « contribution » respective des sexes reflète des préjugés de l'époque qu'il importe de signaler ; voir, pour une mise au point critique, Sophia M. Connell (éd.), ''The Cambridge Companion to Aristotle's Biology'', Cambridge, Cambridge University Press, 2021.</ref>. === Substance, essence et ''ousia'' === Le terme grec ''ousia'', traduit en latin par ''substantia'' ou ''essentia'', possède des significations différentes chez Platon et Aristote. Comme le note Pellegrin, ''ousia'' est en fait « difficile à traduire, car le terme représente, chez Aristote, trois choses différentes : la forme, la matière, et le composé des deux »<ref>Pierre Pellegrin, ''Dictionnaire Aristote'', op. cit., article « Ousia / Substance », p. 175-176, citant ''Métaphysique'', Z, 3, 1029a27-33.</ref>. Chez Platon, l{{'}}''ousia'' désigne principalement l'Idée, la réalité véritable, immuable et éternelle, par opposition aux choses sensibles<ref>Platon, ''Phédon'', 78c-d ; ''République'', V, 477a-478e ; VI, 484b-486d.</ref>. Chez Aristote, l{{'}}''ousia'' désigne avant tout, dans les ''Catégories'', la substance première : l'individu concret qui existe par lui-même<ref>Aristote, ''Catégories'', 5, 2a11-19 ; ''Métaphysique'', Z, 3, 1028b33-1029a7.</ref>. Aristote distingue alors la substance première de la substance seconde, qui désigne l'espèce ou le genre auquel appartient l'individu<ref>Aristote, ''Catégories'', 5, 2a14-17.</ref>. Le rapport entre cette doctrine des ''Catégories'' et la théorie de la substance développée dans les livres centraux de la ''Métaphysique'' (Z, H, Θ) est l'un des points les plus discutés de l'aristotélisme contemporain. Annick Jaulin souligne que la « doctrine » aristotélicienne de la substance n'est pas une donnée stabilisée mais l'objet d'une recherche : Aristote « invente une théorie de la forme qui intègre le mouvement »<ref>Annick Jaulin, ''Aristote. La métaphysique'', op. cit., Introduction.</ref>. Dans la ''Métaphysique'', la forme (''eidos'') apparaît comme le meilleur candidat au titre de substance, mais cette identification soulève à son tour la question de l'unité du composé hylémorphique<ref>Pour un panorama des débats, voir Christof Rapp et Klaus Corcilius (dir.), ''Aristoteles-Handbuch'', Stuttgart, Metzler, 2011, chap. IV.18 ; Vasilis Politis, ''Routledge Philosophy GuideBook to Aristotle and the Metaphysics'', op. cit., chap. 5-7.</ref>. Comme le résume Pellegrin, « ''ousia'' signifie donc la chose (la substance) et ses causes internes, la matière et la forme ; la forme est ce qui correspond à l'essence »<ref>Pierre Pellegrin, ''Dictionnaire Aristote'', op. cit., p. 176.</ref>. === Éléments de continuité entre Platon et Aristote === Au-delà des divergences ontologiques, Platon et Aristote partagent plusieurs convictions communes. La première est la primauté de la forme sur la matière dans l'ordre de l'intelligibilité, de la réalité et de la perfection. Pour Platon comme pour Aristote, la matière pure, prise en elle-même, est indéterminée<ref>Platon, ''Timée'', 49a-52d ; Aristote, ''Métaphysique'', Z, 10, 1036a8-9.</ref>. La deuxième est la finalité dans la nature. Platon présente le cosmos comme l'œuvre d'un Démiurge qui organise la matière en regardant vers les Idées<ref>Platon, ''Timée'', 28a-30c.</ref>. Aristote intériorise et naturalise cette téléologie : la nature elle-même agit en vue d'une fin (''hê phusis heneka tou poiei''), sans qu'il soit besoin de postuler un Démiurge extérieur ou des Idées transcendantes<ref>Aristote, ''Physique'', II, 8, 198b10-199a8 ; ''Des Parties des Animaux'', I, 1, 639b14-640a9. Pour une discussion détaillée de la téléologie aristotélicienne, voir Allan Gotthelf, ''Teleology, First Principles, and Scientific Method in Aristotle's Biology'', Oxford, Oxford University Press, 2012 ; James G. Lennox, ''Aristotle's Philosophy of Biology'', Cambridge, Cambridge University Press, 2001.</ref>. La troisième est la connaissance par les causes : pour les deux philosophes, la véritable connaissance scientifique consiste à connaître les causes (''aitiai'') des choses<ref>Platon, ''Phédon'', 96a-99d ; Aristote, ''Physique'', II, 3, 194b16-195b30 ; ''Seconds Analytiques'', I, 13, 78a22-b4.</ref>. Aristote reproche cependant à Platon d'avoir négligé la cause efficiente et la cause finale<ref>Aristote, ''Métaphysique'', A, 6, 988a8-17 ; A, 9, 992a24-29 ; Λ, 10, 1075b37-1076a4.</ref>. La quatrième concerne la définition et l'universel. Platon et Aristote s'accordent sur le fait que la définition (''horismos'' ou ''logos'') porte sur l'universel et non sur le particulier<ref>Platon, ''Théétète'', 201c-210b ; Aristote, ''Métaphysique'', Z, 15, 1039b27-1040a7.</ref>. Mais Aristote opère une distinction fondamentale entre l'ordre de l'être (où le particulier est premier) et l'ordre de la connaissance (où l'universel est premier)<ref>Aristote, ''Métaphysique'', Z, 13, 1038b8-16 ; ''Seconds Analytiques'', I, 2, 71b33-72a5.</ref>. === La dialectique et le rôle de l'expérience sensible === Platon et Aristote sont tous deux héritiers de la méthode dialectique inaugurée par Socrate, qui consiste à examiner les opinions par le dialogue argumenté<ref>Platon, ''République'', VII, 533c-534e ; Aristote, ''Topiques'', I, 1-2, 100a18-101b4.</ref>. Aristote conserve cette approche : il commence presque toujours par recenser les opinions des prédécesseurs (''doxographia''), examine les difficultés (''aporiai''), avant de proposer sa propre solution<ref>Aristote, ''Éthique à Nicomaque'', VII, 1, 1145b2-7.</ref>. Cependant, pour Aristote, la dialectique est un outil préparatoire et non une science apodictique<ref>Aristote, ''Topiques'', I, 2, 101a25-b4 ; ''Métaphysique'', Γ, 2, 1004b17-26.</ref>. Une divergence importante porte sur le rôle de l'expérience sensible. Pour Platon, les sens nous maintiennent dans l'opinion (''doxa'')<ref>Platon, ''Phédon'', 66b ; ''République'', VII, 514a-517c.</ref>. Aristote accorde au contraire une place fondamentale à l'expérience sensible : la science porte sur l'universel, mais cet universel est abstrait à partir des données sensibles par un processus d'induction (''epagôgê'')<ref>Aristote, ''Seconds Analytiques'', II, 19, 100b3-5, trad. J. Tricot, Paris, Vrin, 1947, p. 242 ; ''De l'Âme'', III, 8, 432a3-10.</ref>. === Bilan : continuité et rupture === Une partie de l'historiographie contemporaine décrit l'aristotélisme comme une reformulation immanentiste des problèmes hérités du platonisme : la forme « descend » du ciel des Idées pour s'incarner dans les choses, le Bien se réalise dans l'accomplissement de la nature propre de chaque être, la philosophie reconnaît que seuls existent les individus concrets dans lesquels l'universel se réalise. Cette caractérisation, parfois résumée par l'expression « naturalisation du platonisme », demeure toutefois une thèse interprétative dont la portée varie selon les commentateurs ; elle ne fait pas consensus dans la recherche universitaire et doit être attribuée à des travaux secondaires précis<ref>Cette interprétation est défendue notamment par certains chercheurs proches de la tradition de Pierre Aubenque ; voir ''Le problème de l'être chez Aristote'', Paris, PUF, 1962. D'autres travaux, notamment Annick Jaulin, ''Aristote. La métaphysique'', op. cit., insistent au contraire sur l'originalité radicale de la démarche aristotélicienne et son enracinement dans la tradition des penseurs de la nature, plus que dans une « naturalisation » du platonisme.</ref>. Plus généralement, la querelle entre Platon et Aristote est l'une des plus discutées de l'histoire de la métaphysique occidentale. La tradition philosophique ultérieure n'a jamais tranché définitivement, oscillant entre tentatives de conciliation (néoplatoniciens, Thomas d'Aquin) et radicalisations de l'opposition (nominalistes médiévaux, empiristes modernes). == La logique : l’''Organon''== ===Présentation générale=== L’''Organon'' désigne le groupe des six traités logiques d’Aristote : les ''Catégories'', le ''De interpretatione'' (ou ''Peri hermeneias''), les ''Premiers Analytiques'', les ''Seconds Analytiques'', les ''Topiques'' et les ''Réfutations sophistiques''. Ces ouvrages couvrent l’étude des termes simples, celle des propositions, la théorie générale du syllogisme, la théorie de la science démonstrative, l’art de la dialectique et la critique des raisonnements fallacieux. Ils ont fourni à la pensée occidentale, pendant deux millénaires, le cadre dominant pour analyser les conditions du raisonnement valide, l’architecture du savoir scientifique et les règles du débat rationnel. L’importance historique de l’ensemble est grande. C’est dans ces traités qu’Aristote dégage pour la première fois la notion de syllogisme, distingue formellement la validité d’un raisonnement de la vérité de ses prémisses, élabore les instruments logiques liés à la contradiction, au tiers exclu et à la bivalence, et propose une théorie de l’explication scientifique fondée sur la connaissance des causes. La défense philosophique du principe de non-contradiction se trouve, elle, surtout dans la ''Métaphysique'' (livre Γ), et non dans l’''Organon'' lui-même. La syllogistique aristotélicienne a été enseignée dans les écoles et les universités jusqu’au XIX{{e}} siècle, et ses concepts continuent de nourrir des débats contemporains en philosophie de la logique, en métaphysique modale et en philosophie des sciences. L’unité de l’ensemble est cependant problématique. Aristote n’a jamais composé un ouvrage intitulé ''Organon'' ; le terme et le regroupement sont des opérations éditoriales tardives, attribuées à la tradition péripatéticienne et notamment à Andronicos de Rhodes au I{{er}} siècle avant notre ère. L’ordre traditionnel des traités, qui place la théorie de la science démonstrative au cœur du dispositif, traduit un choix interprétatif qui n’est probablement pas celui d’Aristote lui-même : la recherche contemporaine tend à attribuer aux ''Topiques'' une ancienneté relative et à voir dans la syllogistique formelle un développement plus tardif. Lire la logique aristotélicienne demande donc de tenir compte simultanément de la lettre des traités, de la tradition exégétique qui les a reçus et organisés, et des hypothèses de la recherche actuelle qui en renouvelle les enjeux. ===Architecture et destinée de l’''Organon''=== ====Le titre « ''Organon'' » : une création tardive==== Le terme grec ''organon'' signifie « instrument », « outil ». La désignation des six traités logiques sous ce titre commun obéit à une tradition qui ne remonte pas, semble-t-il, à Aristote lui-même.<ref>Pellegrin, P., « Introduction générale à l’''Organon'' », dans Aristote, ''Catégories'' / ''Sur l’interprétation'', GF Flammarion, 2007, p. 7-50.</ref> Pierre Pellegrin rappelle, dans son introduction générale à l’''Organon'' paru chez Flammarion, que « "logique", "instrument" sont des termes qu’Aristote emploie en leurs acceptions ordinaires », et non au sens technique que leur ont donné les commentateurs néo-platoniciens. Quand le biologiste Aristote parle d’''organon'', il pense d’abord à l’organe corporel et accessoirement à l’instrument artificiel ; c’est avec la lecture stoïcienne et péripatéticienne que cet « instrument » devient l’instrument ''par excellence'' de la philosophie, c’est-à-dire la logique. L’enjeu de cette précision dépasse l’érudition. Si la qualification de « logique » et le regroupement des six traités sous l’étiquette « Organon » sont des opérations posthumes, l’ordre traditionnel dans lequel on lit aujourd’hui ces textes (''Catégories'', ''De interpretatione'', ''Premiers Analytiques'', ''Seconds Analytiques'', ''Topiques'', ''Réfutations sophistiques'') n’a rien d’évident ni de nécessaire. Cet ordre s’impose à partir du IV{{e}} siècle de notre ère, et il porte la marque d’un choix interprétatif fondamental. ====L’intervention d’Andronicos de Rhodes==== Vers le milieu du I{{er}} siècle avant notre ère, Andronicos de Rhodes entreprend une édition systématique du corpus aristotélicien à laquelle nous devons en grande partie le texte que nous possédons aujourd’hui. Selon une lecture défendue notamment par Pellegrin et Brunschwig, et qui doit être présentée comme une interprétation forte plutôt que comme un fait définitivement établi, l’intervention d’Andronicos aurait avant tout consisté à réduire la part de la dialectique au profit de celle de l’analytique.<ref>Voir Pellegrin, P., « Introduction générale à l’''Organon'' », dans Aristote, ''Catégories'' / ''Sur l’interprétation'', GF Flammarion, 2007, p. 26-44 ; Brunschwig, J., introduction à Aristote, ''Topiques'' (livres I-IV), Les Belles Lettres, 1967, rééd. 2007, p. XXXIII-XLVIII.</ref> Pellegrin résume ainsi ce travail : la « logique » d’Aristote, qu’il n’appelait peut-être pas ainsi mais qui reçut très rapidement ce nom sous l’influence de la division stoïcienne de la philosophie, était avant Andronicos principalement un ensemble de traités consacrés à des activités dialectiques et notamment réfutatives. Cette réorganisation, si on l’admet, a des conséquences théoriques importantes. En plaçant les ''Seconds Analytiques'' (la théorie du syllogisme scientifique) au centre de gravité de l’''Organon'', Andronicos oriente la lecture de la logique aristotélicienne vers une épistémologie de la science démonstrative à laquelle tous les autres traités servent de propédeutique. Les ''Catégories'' préparent à l’étude des termes, le ''De interpretatione'' à celle des propositions, les ''Premiers Analytiques'' à l’analyse formelle du syllogisme en général ; les ''Topiques'' et les ''Réfutations sophistiques'' apparaissent comme un appendice consacré aux formes secondaires du raisonnement (raisonnements à partir de prémisses simplement probables, raisonnements fallacieux). Or, comme l’a soutenu Brunschwig dans son édition des ''Topiques'', il est très probable que la chronologie de la composition aristotélicienne aille en partie dans le sens inverse : les ''Topiques'' seraient un traité ancien, écrit du temps où Aristote était encore membre de l’Académie platonicienne, et la syllogistique formelle des ''Premiers Analytiques'' représenterait un développement théorique postérieur, élaboré à partir des problèmes rencontrés dans la pratique dialectique. Pellegrin écrit, en synthétisant cette hypothèse : « Le syllogisme scientifique serait alors une forme particulière du syllogisme en général qui aurait des contraintes supplémentaires, au moins au nombre de deux : avoir des prémisses vraies, antérieures à la conclusion et plus connues qu’elle, et donner dans le moyen terme la cause de la conclusion. » Si cette hypothèse est correcte, ce que nous appelons la « logique aristotélicienne » serait, dans son projet originel, une codification de l’affrontement dialectique, c’est-à-dire d’une pratique d’argumentation entre un ''questionneur'' et un ''répondant'' ; la théorie du syllogisme scientifique en constituerait une spécification ultérieure. Comme l’écrit Brunschwig, cité par Pellegrin, le questionneur dialectique « doit construire une argumentation formellement contraignante, ayant pour prémisses des propositions auxquelles le répondant ne puisse refuser son assentiment, et pour conclusion la proposition contradictoire de celle que soutient le répondant. » Toute la logique aristotélicienne, dans cette perspective, naîtrait du souci de codifier cet exercice argumentatif et de distinguer les bons arguments des mauvais. ====Logique : partie de la philosophie ou instrument ?==== Une question agitée dès l’Antiquité, et sur laquelle Aristote lui-même ne se prononce jamais explicitement, est celle du statut de la logique. Est-elle une ''partie'' de la philosophie, comme le soutiendront les Stoïciens (qui distinguent éthique, physique et logique), ou en est-elle un ''instrument'' (organon), c’est-à-dire un outil méthodologique préalable à toute investigation philosophique ? La tradition péripatéticienne, et notamment Alexandre d’Aphrodise, défend la seconde thèse : la logique n’est pas une science indépendante avec son propre objet, mais une discipline formelle qui prépare à l’étude de tout objet. Cette conception coïncide avec le statut subordonné qu’Aristote accorde lui-même à l’analyse logique : la logique étudie les ''formes'' du raisonnement valide, indépendamment du ''contenu'' particulier des propositions. Cette caractérisation appelle une nuance. La logique aristotélicienne n’est pas purement formelle au sens contemporain du terme. Comme l’a montré Paolo Crivelli, « bien qu’on puisse créditer Aristote de la thèse selon laquelle la logique est formelle, la manière dont elle est formelle pour lui diffère de celle dont elle l’est pour beaucoup de philosophes et logiciens modernes ».<ref>Crivelli, P., « Truth and Formal Validity in the Prior Analytics », dans A. P. Mesquita et R. Santos (dir.), ''New Essays on Aristotle’s Organon'', Routledge, 2024, chap. 4.</ref> La forme du raisonnement, chez Aristote, n’est pas une pure structure syntaxique substituable à n’importe quelle matière : elle implique des relations sémantiques entre les termes, des relations qui présupposent une certaine ontologie. La logique aristotélicienne est traversée, du début à la fin, par des questions qui, du point de vue moderne, relèvent plutôt de la sémantique, de l’ontologie et de la métaphysique. ====Ce qu’il faut retenir==== L’''Organon'' n’est pas un livre composé par Aristote mais un regroupement éditorial dû à la tradition péripatéticienne. L’ordre traditionnel des traités traduit une lecture orientée vers la science démonstrative, contre laquelle la recherche contemporaine (Brunschwig, Pellegrin) propose une hypothèse alternative selon laquelle la dialectique des ''Topiques'' serait antérieure et la syllogistique formelle un développement postérieur. La logique aristotélicienne se présente comme un instrument plutôt qu’une partie de la philosophie, mais elle reste, dans son fonctionnement même, traversée par des questions sémantiques et ontologiques. ===Chapitre I. ''Les Catégories'' : les genres suprêmes de l’être=== ====Le titre, l’objet et l’authenticité du traité==== Le traité que la tradition désigne sous le titre de ''Catégories'' (en grec ''Katêgoriai'', plus rarement ''Pro tôn topôn'', « Préliminaire aux ''Topiques'' ») se présente comme une œuvre brève, divisée en deux parties hétérogènes.<ref>Sur le titre ''Pro tôn topôn'' et son sens, voir Bodéüs, R., introduction à Aristote, ''Catégories'', Les Belles Lettres, 2002.</ref> La première (chapitres 1 à 9) traite des « antéprédicaments » (homonymes, synonymes, paronymes ; choses dites avec ou sans combinaison) puis des dix catégories proprement dites, avec une attention particulière portée à la substance, à la quantité, à la relation et à la qualité. La seconde partie (chapitres 10 à 15), appelée « post-prédicaments », traite de notions plus diverses : les opposés, l’antériorité, la simultanéité, le mouvement et les sens du verbe « avoir ». L’authenticité de cette seconde partie a été contestée dès Andronicos, et elle continue de l’être. L’authenticité même de la première partie a été discutée dès l’Antiquité. Pellegrin, à la fin de son introduction au traité, conclut prudemment que le fait, indéniable, que la philosophie d’Aristote serait inintelligible sans la doctrine des catégories n’établit nullement l’authenticité du traité éponyme.<ref>Pellegrin, P., « Présentation des ''Catégories'' », dans Aristote, ''Catégories'' / ''Sur l’interprétation'', GF Flammarion, 2007, p. 67-92, ici p. 90-92.</ref> Que la doctrine soit aristotélicienne, et qu’elle soit centrale pour l’aristotélisme, est indubitable ; mais cela ne prouve pas que ce traité particulier ait été composé par Aristote en personne. Il pourrait s’agir d’un manuel péripatéticien tardif fidèle à la pensée du maître. Pour notre propos, peu importe : nous nous attacherons à exposer la doctrine telle qu’elle se présente dans le texte canonique. ====Les antéprédicaments : homonymes, synonymes, paronymes==== Le traité s’ouvre sur trois définitions qui paraissent, au premier abord, purement linguistiques mais qui ont en réalité une portée ontologique réelle. Sont dits homonymes, écrit Aristote, « les objets dont le nom seul est commun, alors que l’énonciation correspondant à ce nom est différente ».<ref>Aristote, ''Catégories'' 1, 1a1-2.</ref> L’exemple canonique est celui du mot grec ''zôion'', qui désigne aussi bien l’animal vivant que la figure peinte ou dessinée (le peintre, en grec, se dit ''zôgraphos'', de ''graphein'' qui peut signifier écrire, tracer, dessiner ou peindre). Un homme et la représentation peinte d’un homme reçoivent l’un et l’autre le nom ''zôion'', mais la définition de ce qu’est, pour chacun, « être un ''zôion'' » est radicalement différente : pour l’homme, ''zôion'' signifie « être animé doué de sensation » ; pour la peinture, ''zôion'' signifie « figure représentée sur une surface ». L’homonymie n’est donc pas seulement un fait linguistique : elle révèle que ce qui partage un même nom peut relever d’essences entièrement distinctes. Sont dits synonymes au contraire « les objets dont le nom est commun, et pour lesquels l’énonciation correspondant à ce nom est la même ».<ref>Aristote, ''Catégories'' 1, 1a6-12.</ref> Un homme et un bœuf sont tous deux « animal », et l’énonciation de ce que c’est, pour eux, qu’être animal (vivant doué de sensation) est identique. L’usage aristotélicien de ''sunônumon'' est donc presque l’inverse du nôtre : la synonymie ne désigne pas deux mots qui ont le même sens, mais deux objets qui appartiennent à la même espèce ou au même genre. Sont dits enfin paronymes « les objets qui tiennent leur appellation d’un certain objet, alors qu’ils en diffèrent par la dérivation » : ''grammatikos'' (le lettré) tient son nom de ''grammatikê'' (le savoir-lire), ''andreios'' (le courageux) tient son nom de ''andreia'' (le courage).<ref>Aristote, ''Catégories'' 1, 1a12-15.</ref> Le paronyme partage la racine du nom de la qualité dont il est porteur, mais il s’en distingue par sa flexion morphologique. Ces trois catégories ne sont pas de simples préliminaires : elles structurent toute la suite du traité. L’homonymie permettra de distinguer entre des emplois irréductibles d’un même mot ; la synonymie sera invoquée pour caractériser le rapport entre la substance première et la substance seconde ; la paronymie permettra de comprendre comment des qualités donnent lieu à des qualifications. La question de l’« unité de l’être », sur laquelle nous reviendrons, prend également racine dans cette opposition entre homonymie et synonymie : si l’être est dit en plusieurs sens, est-il homonyme purement et simplement, ou s’agit-il d’une ''homonymie focale'' (selon la terminologie introduite par G.E.L. Owen) où plusieurs sens se rapportent à un sens premier ? ====Termes dits avec et sans combinaison ; les quatre classes d’étants==== Au chapitre 2, Aristote introduit deux distinctions importantes. La première oppose ce qui « est dit selon une combinaison » (par exemple « un homme court », « un homme gagne ») à ce qui « est dit sans combinaison » (par exemple « homme », « bœuf », « court », « gagne »). Cette distinction prépare l’analyse des catégories qui, comme termes simples, sont précisément ce qui est dit ''sans combinaison''. L’analyse des combinaisons (c’est-à-dire des propositions) sera réservée au ''De interpretatione''. La seconde distinction est plus subtile et constitue l’épine dorsale de la doctrine ontologique du traité. Aristote distingue, parmi les étants, ceux qui « se disent d’un sujet » et ceux qui « sont dans un sujet ».<ref>Aristote, ''Catégories'' 2, 1a20-1b9.</ref> Il en résulte quatre classes : (1) Ce qui se dit d’un sujet ''et'' est dans un sujet : par exemple le ''savoir'' (en général), qui se dit d’un sujet (par exemple le savoir-lire dont il est le genre) et qui est dans l’âme (sujet d’inhérence). Ce sont les universels non substantiels. (2) Ce qui se dit d’un sujet mais n’est dans aucun sujet : par exemple l’''homme'' en tant qu’espèce, qui se dit de tel homme particulier (Socrate) mais n’est pas une qualité ou un accident ''inhérent'' à un substrat. Ce sont les universels substantiels. (3) Ce qui est dans un sujet mais ne se dit d’aucun sujet : par exemple ''tel savoir-lire particulier'' qui réside dans une âme particulière, ou ''tel blanc particulier'' présent dans un corps particulier. Ce sont les individus accidentels (parfois appelés « tropes » dans la philosophie analytique contemporaine). (4) Ce qui ne se dit d’aucun sujet et n’est dans aucun sujet : c’est ''Socrate'', ''tel cheval'', ''tel arbre'', les substances premières au sens strict. La distinction fondamentale est ici celle entre prédication (« se dire de ») et inhérence (« être dans »). La prédication transporte avec elle la définition : si « animal » se dit de Socrate, alors la définition d’« animal » s’applique à Socrate (Socrate est un être animé doué de sensation). L’inhérence, en revanche, ne transporte pas la définition : « blanc » est dans Socrate, mais Socrate n’est pas la définition de « blanc ». Cette différence est centrale pour comprendre comment les genres et les espèces sont des « substances secondes » alors que la couleur ou la qualité ne le sont pas. ====Les dix catégories : une liste, deux interprétations==== Le chapitre 4 livre la liste canonique des dix « genres suprêmes ». Aristote écrit que « chacun des termes qui sont dits sans aucune combinaison indique soit une substance (''ousia''), soit une certaine quantité (''poson''), soit une certaine qualité (''poion''), soit un rapport à quelque chose (''pros ti''), soit quelque part (''pou''), soit un certain moment (''pote''), soit être dans une position (''keisthai''), soit posséder (''echein''), soit faire (''poiein''), soit subir (''paschein'') ».<ref>Aristote, ''Catégories'' 4, 1b25-27.</ref> Les exemples qu’il donne sont volontairement concrets : un homme, un cheval (substance) ; long de deux coudées, long de trois coudées (quantité) ; blanc, instruit (qualité) ; double, moitié (relation) ; au Lycée, sur l’Agora (lieu) ; hier, l’an dernier (temps) ; couché, assis (position) ; chaussé, armé (possession) ; couper, brûler (action) ; être coupé, être brûlé (passion). Cette liste a posé, dès l’Antiquité, deux types de problèmes. Premièrement, elle n’est pas systématiquement reprise par Aristote : dans plusieurs autres traités, on trouve des listes plus courtes (à huit ou six catégories), et Aristote ne fournit nulle part une justification rigoureuse du nombre dix. Deuxièmement, son objet même est contesté : s’agit-il de divisions du langage, de divisions de la pensée, ou de divisions de la réalité ? Pellegrin rappelle dans son introduction la pluralité des interprétations historiques. Friedrich Trendelenburg, au XIX{{e}} siècle, a soutenu une interprétation grammaticale : les catégories correspondraient aux parties du discours grec, les quatre premières (substance, quantité, qualité, relation) aux noms et adjectifs, les quatre dernières (action, passion, position, possession) aux verbes, les deux intermédiaires (lieu, temps) aux adverbes. Cette lecture a l’avantage de réduire l’apparent désordre de la liste, mais elle a été critiquée par Émile Benveniste qui a objecté que la grammaire grecque ne propose pas exactement cette division. Une lecture ontologique, au contraire, voit dans les catégories les genres suprêmes de l’étant : Franz Brentano, dans sa thèse de 1862 ''Von der mannigfachen Bedeutung des Seienden nach Aristoteles'' (''De la diversité des acceptions de l’être d’après Aristote''), en a donné l’exposition la plus achevée. Mais cette lecture, comme le note Pellegrin, peine à expliquer le rapport étroit entre catégories et prédication. La solution la plus juste consiste sans doute à reconnaître, avec Pellegrin, que les catégories sont à l’intersection d’une analyse logique du discours et d’une décomposition ontologique de la réalité. Elles sont à la fois des types de prédication et des genres de l’être : la double dimension est constitutive de la doctrine, et toute tentative de réduire l’une à l’autre manque l’originalité du projet aristotélicien. Un éclairage supplémentaire vient des ''Topiques'' (I, 9), où Aristote distingue ce que ''katègoria'' peut désigner : tantôt les dix « catégories » (ce que c’est, qualité, quantité, etc.), tantôt les quatre prédicables (accident, genre, propre, définition), tantôt encore les catégories que l’on signifie quand on signifie une essence. Comme l’explique Pellegrin, quand on dit d’un homme particulier qu’il est un homme, on lui donne comme prédicat essentiel une substance ; quand on dit de telle couleur blanche qu’elle est du blanc, on lui donne comme prédicat essentiel une qualité. La doctrine des catégories articule indissociablement une typologie des relations prédicatives et une typologie des genres d’être : elle est au croisement de la logique et de la métaphysique. ====La substance première et la substance seconde==== Parmi les dix catégories, l’une occupe une position privilégiée : la substance (''ousia''). Aristote écrit, au début du chapitre 5 : « La substance est ce qui se dit proprement, premièrement et avant tout ; ce qui à la fois ne se dit pas d’un certain sujet et n’est pas dans un certain sujet ; par exemple tel homme ou tel cheval ».<ref>Aristote, ''Catégories'' 5, 2a11-14.</ref> La substance première est donc l’individu concret, ce que la tradition appellera plus tard ''tode ti'', « ceci », « ce quelque chose-ci ». Elle se distingue par une double caractéristique négative : elle n’est ni dite d’un sujet (elle n’est pas un prédicat) ni dans un sujet (elle n’est pas une qualité ou un accident inhérent à un substrat). Cette définition a une conséquence ontologique importante : sans les substances premières, rien d’autre ne pourrait exister. « Tous les autres termes, ou bien se disent de sujets qui sont les substances premières, ou bien sont dans des sujets qui sont ces mêmes substances ».<ref>Aristote, ''Catégories'' 5, 2a34-2b6.</ref> Si l’on supprime les hommes et les chevaux particuliers, on supprime du même coup l’espèce « homme » et l’espèce « cheval », qui se disent d’eux ; on supprime également les couleurs, les qualités, les actions, qui sont dans eux. La substance première est le substrat ultime (''hypokeimenon''), ce dont tout se dit mais qui n’est dit de rien d’autre.<ref>Cf. Aristote, ''Métaphysique'' Z, 3, 1028b36-37.</ref> Mais Aristote reconnaît également l’existence de substances secondes : ce sont les espèces et les genres auxquels appartiennent les substances premières. L’homme (espèce) et l’animal (genre) sont des substances secondes parce qu’ils se disent des substances premières. Aristote précise que « parmi les substances secondes, l’espèce est plus substance que le genre, car elle est plus proche de la substance première » : un homme particulier est plus immédiatement reconnu comme « un homme » que comme « un animal », et la définition d’« homme » est plus précise que celle d’« animal ». Cette articulation entre substance première et substance seconde est l’une des thèses les plus originales du traité. D’un côté, Aristote affirme la primauté ontologique de l’individu sur l’universel, ce qui le distingue du Platon des dialogues médians, pour qui les Idées (universelles) sont plus réelles que les choses sensibles. D’un autre côté, il reconnaît à l’espèce et au genre une réalité substantielle, ce qui ne les réduit pas à de purs concepts mentaux. La synonymie y joue un rôle important : la substance et ses différences se disent de façon synonyme de la substance première, c’est-à-dire avec partage de la définition. Quand je dis que « Socrate est homme » et que « Socrate est animal », je transporte sur Socrate les définitions complètes de « homme » et d’« animal » ; ce n’est pas le cas quand je dis « Socrate est blanc », car je n’attribue pas à Socrate la définition de la blancheur (Socrate n’est pas la couleur blanche). Il faut signaler que la doctrine de la substance présentée dans les ''Catégories'' n’est pas identique à celle que développeront les livres centraux de la ''Métaphysique'' (Z, H, Θ). Dans la ''Métaphysique'', Aristote cherche dans la forme ou l’essence (''eidos'') le principe de substantialité de l’individu : la forme n’est pas simplement identifiée à un universel au sens d’un genre ou d’une espèce ; elle est ce qui, dans l’individu lui-même, en fait ce qu’il est et le rend connaissable. Cette recherche d’un principe immanent de substantialité crée une tension avec la doctrine des ''Catégories'', qui assignait la primauté à l’individu concret comme totalité indivise. Cette tension nourrit l’un des grands débats interprétatifs de l’aristotélisme contemporain : la doctrine des ''Catégories'' est-elle une étape antérieure et moins mûre, qu’Aristote dépasse dans la ''Métaphysique'' ? Ou bien les deux doctrines coexistent-elles dans le système, chacune ayant son domaine de validité ? Bodéüs, dans son introduction de l’édition des Belles Lettres, défend une lecture compatibiliste où la substance première des ''Catégories'' (l’individu) reste première en un sens, tandis que la ''Métaphysique'' cherche, à l’intérieur de l’individu, ce qui en fait la substantialité (sa forme). ====Les propriétés caractéristiques de la substance==== Les chapitres 5 à 9 des ''Catégories'' dégagent ensuite plusieurs propriétés permettant d’identifier la substance et de la distinguer des accidents. Ces « propriétés topiques » ne visent pas à donner une définition de la substance, mais, comme le précise une note de l’édition Flammarion, à préparer le lecteur à construire et à utiliser les lieux appropriés à un type de prédication donné. Elles sont des outils dialectiques utiles dans la discussion. Première propriété : toute substance n’est pas dans un sujet. Cette propriété ne lui est cependant pas exclusive ; les différences (''diaphorai'') ne sont pas non plus dans un sujet, sans être pour autant des substances. Aristote précise aussi que les parties des substances (la main, le pied) sont elles-mêmes des substances, contre une objection qui pourrait les considérer comme des qualités du tout. Deuxième propriété : la substance et ses différences se disent de façon synonyme. Quand je dis que « Socrate est homme » et que « Socrate est bipède » (où « bipède » est une différence spécifique de l’homme), je transporte la définition complète de chacun de ces termes sur Socrate. Troisième propriété : toute substance « indique un certain ceci » (''tode ti''). Cette caractéristique vaut éminemment pour les substances premières, qui sont des individus numériquement uns. Pour les substances secondes (espèce, genre), Aristote précise qu’elles désignent plutôt « une certaine sorte d’objet » (''poion ti'') : non pas un individu particulier, mais le type auquel des individus appartiennent. Les substances secondes sont substances en ce qu’elles fondent l’identité essentielle des substances premières, mais elles ne sont pas des « ceci » au même titre. Quatrième propriété : la substance n’admet pas le plus ou le moins. On ne dit pas qu’un homme est « plus homme » qu’un autre, ni qu’un cheval est « moins cheval » : être homme ou être cheval ne souffre pas de degrés. Au contraire, les qualités admettent le plus ou le moins (un homme peut être plus blanc qu’un autre, plus instruit, plus juste). Cinquième propriété, la plus remarquable : la substance est capable de recevoir les contraires tout en restant la même et numériquement une. Le même Socrate peut être tantôt assis, tantôt debout ; tantôt en bonne santé, tantôt malade ; tantôt joyeux, tantôt triste. Cette capacité à supporter le changement tout en demeurant identique à soi-même constitue, selon Aristote, le « caractère le plus propre » de la substance ; c’est ce qui en fait précisément le substrat (''hypokeimenon'') du devenir. Une qualité particulière ne peut pas recevoir les contraires : telle blancheur ne peut devenir noirceur (elle est remplacée par une noirceur, qui est une autre qualité). Seule la substance traverse le changement sans cesser d’être ce qu’elle est. ====L’être n’est pas un genre : l’unité problématique de l’étant==== La doctrine des catégories soulève une difficulté philosophique : si l’être se dit en plusieurs sens irréductibles (être substance, être qualité, être quantité, etc.), comment préserver l’unité de son concept ? Cette question, qu’Aristote pose dans la ''Métaphysique'' (B, 3, 998b22), reçoit une réponse qui structure son ontologie : l’être n’est pas un genre. La raison en est, comme l’explique l’''Aristoteles-Handbuch'', un argument technique sous forme de ''reductio ad absurdum'' : si l’être était un genre, alors les différences spécifiques qui le diviseraient en espèces seraient elles-mêmes des étants ; or aucun genre ne peut être prédiqué de ses différences spécifiques (sinon les différences seraient déjà comprises dans le genre, et la division spécifique serait impossible) ; donc, si l’être était un genre, ses différences ne seraient pas des étants, ce qui est absurde.<ref>Rapp, C., et Corcilius, K. (dir.), ''Aristoteles-Handbuch'', Stuttgart, J. B. Metzler, 2021, partie IV.</ref> L’être ne peut donc pas être un genre au sens propre. Mais alors, l’être est-il purement homonyme ? Aristote refuse cette conséquence. Il développe, principalement dans le livre Γ de la ''Métaphysique'', une troisième voie : les différentes acceptions de l’être ne sont ni univoques (comme à l’intérieur d’un genre) ni purement équivoques (comme dans le cas de l’homonymie au sens strict), mais elles se rapportent toutes à un terme premier, la substance. « L’être se dit en multiples acceptions, mais toujours relativement à un terme unique, à une nature déterminée » (Γ, 2, 1003a33-b10). Une qualité ne peut être qu’en tant qu’elle est ''qualité d’une substance'' ; une quantité ne peut être qu’en tant qu’elle est ''quantité d’une substance''. La substance constitue le pôle de référence par rapport auquel les autres significations de l’être se définissent. Cette structure est ce que la tradition appelle ''homonymie focale'' ou ''signification focale'' (''focal meaning'', dans la formulation de G.E.L. Owen). L’''Aristoteles-Handbuch'' l’illustre par un exemple aristotélicien : la santé est dite, en sens divers, du teint, de la gymnastique, de la médecine, et de l’homme. Ces sens ne sont pas univoques (un teint sain et un homme sain ne sont pas « sains » de la même façon), mais ils ne sont pas non plus purement homonymes : tous se rapportent à la santé de l’homme (le teint ''signe'' cette santé, la gymnastique la ''préserve'', la médecine la ''produit''). De même pour l’être : tous les modes d’être se rapportent à la substance, qui constitue le « cas privilégié » par rapport auquel les autres se définissent. ====Ce qu’il faut retenir==== Les ''Catégories'' opèrent au croisement de la logique, de la grammaire et de l’ontologie. Le traité distingue homonymes, synonymes et paronymes ; il présente quatre classes d’étants articulées par les relations de prédication et d’inhérence ; il dresse la liste des dix catégories ; il accorde la primauté à la substance première (l’individu concret) tout en reconnaissant l’existence des substances secondes (espèces et genres). La doctrine de l’''homonymie focale'' permet à Aristote de préserver une unité conceptuelle de l’être sans en faire un genre univoque, ouvrant ainsi la voie à la métaphysique. ===Chapitre II. ''Le De interpretatione'' (Peri hermeneias) : le jugement et la proposition=== ====Titre, objet et méthode du traité==== Le titre grec du traité, ''Peri hermeneias'', désigne « ce qui concerne l’interprétation », mais avec une nuance importante : ''hermeneia'' ne renvoie pas à un ''acte'' (qui serait ''hermeneusis''), mais à ''son produit'' ; non pas l’interpréter, mais l’interprété. Catherine Dalimier, dans son introduction à l’édition Flammarion, propose d’y voir un traité « sur l’expression de la proposition déclarative » (''Peri tou logou apophantikou hermeneias''), c’est-à-dire sur cette forme particulière de discours qui est susceptible d’être vraie ou fausse.<ref>Dalimier, C., introduction au ''De l’interprétation'', dans Aristote, ''Catégories'' / ''Sur l’interprétation'', GF Flammarion, 2007.</ref> L’objet du traité, à la différence des ''Catégories'' qui s’intéressaient aux termes simples (atomes logiques), est la proposition déclarative comme unité minimale de la connaissance et de la communication. Aristote y examine la structure du discours qui peut être validé comme vrai ou faux, les conditions du jugement, les rapports entre langage, pensée et réalité. C.W.A. Whitaker, dans une interprétation que Pellegrin et Dalimier trouvent éclairante, a proposé de lire l’ensemble du traité comme une exploration systématique de la contradiction (''antiphasis''), du « couple de contradictoires ».<ref>Whitaker, C.W.A., ''Aristotle’s'' De Interpretatione: ''Contradiction and Dialectic'', Oxford, Clarendon Press, 1996.</ref> La proposition serait étudiée non pas pour elle-même, mais en tant qu’elle s’inscrit dans un couple affirmation/négation qui constitue l’unité minimale de toute évaluation logique. Cette lecture restitue au ''De interpretatione'' sa cohérence interne et le rattache aux ''Topiques'' et aux ''Réfutations sophistiques'', où la contradiction joue un rôle central. ====Le triangle sémiotique : choses, pensées, mots==== Le traité s’ouvre, au chapitre 1, sur une réflexion sur le statut du langage qui constitue l’un des textes fondateurs de la philosophie occidentale du signe. Aristote y établit un triangle sémiotique à trois étages. Les sons émis par la voix (''ta en tê phonê'') sont des symboles (''symbola'') des affections de l’âme (''ta en tê psychê pathêmata''), c’est-à-dire des pensées, des concepts mentaux. Les mots écrits sont à leur tour des symboles des mots prononcés. Les pensées, enfin, sont des ressemblances (''homoiômata'') des choses (''pragmata'').<ref>Aristote, ''De l’interprétation'' 1, 16a3-8.</ref> On obtient donc trois niveaux : les choses ; les concepts mentaux qui leur ressemblent ; les sons et les écritures qui symbolisent ces concepts. Ce qui importe, c’est la ''différence de relation'' entre les niveaux. La relation entre les sons (ou les écritures) et les pensées est conventionnelle (''kata sunthêkên'') : « De même que l’écriture n’est pas la même pour tous les hommes, les mots parlés ne sont pas non plus les mêmes ». La diversité des langues humaines manifeste précisément ce caractère conventionnel : il n’y a pas de raison naturelle pour laquelle telle pensée devrait être exprimée par tel son plutôt que par tel autre. En revanche, la relation entre les pensées et les choses est une relation de ressemblance : les états de l’âme « sont identiques chez tous », parce qu’ils reflètent la structure même du réel, qui est, elle, universelle. Cette doctrine a plusieurs conséquences. D’abord, elle prend ses distances avec le naturalisme cratylien : aucun mot n’est par nature le nom de telle chose, c’est l’institution sociale qui assigne tel signe à tel signifié. Ensuite, elle fonde la possibilité d’une communication véridique : si les pensées sont des reflets fidèles des choses, alors la traduction et la compréhension interlinguistique sont possibles, parce qu’au-delà de la diversité des conventions linguistiques, on retrouve une communauté de concepts mentaux qui est elle-même garantie par l’unité du réel. Enfin, elle implique une certaine subordination du langage à la pensée : ce que le langage fait, c’est exprimer (par signes conventionnels) des pensées qui, elles, ressemblent aux choses ; le langage n’est pas le lieu primaire de la vérité, mais sa manifestation extérieure. Aristote précise immédiatement, au chapitre 1, que la vérité et la fausseté n’apparaissent qu’avec la composition ou la séparation. Un mot isolé (« homme », « blanc », « cheval ») ne peut être ni vrai ni faux. Même un mot qui désigne quelque chose d’inexistant (« bouc-cerf », hybride imaginaire) signifie quelque chose, mais n’est ni vrai ni faux : il faut, pour qu’un énoncé soit susceptible d’être vrai ou faux, qu’on y ajoute « être » ou « ne pas être », qu’on attribue ou qu’on nie quelque chose de quelque chose. La vérité est d’abord propositionnelle. ====Le nom (''onoma'')==== Avant d’examiner la proposition, Aristote analyse ses constituants. Le nom (''onoma'') est défini, au chapitre 2, comme « un vocable signifiant par convention, sans référence à un temps, et dont aucune partie, considérée séparément, n’est signifiante ».<ref>Aristote, ''De l’interprétation'' 2, 16a19-21.</ref> Cette définition condense quatre caractères : (1) Le nom est un vocable (''phonê''), une réalité phonique. Cette précision exclut, par exemple, les sons des animaux (qui ne sont pas des mots) ou les bruits inarticulés. (2) Il est signifiant : il a un sens, il indique quelque chose. (3) Il signifie par convention (''kata sunthêkên'') : aucun vocable n’est nom par nature ; il ne le devient que lorsqu’il est institué comme symbole. (4) Il est sans référence au temps : c’est précisément ce qui le distingue du verbe. « Santé » est un nom, parce qu’il signifie quelque chose sans indiquer si cette chose existe maintenant, a existé ou existera ; « est-en-bonne-santé » est un verbe, parce qu’il consignifie le temps présent. (5) Aucune de ses parties n’est signifiante prise séparément. Aristote prend l’exemple grec ''Kalippos'' (« Beaucheval », un nom propre) : ''kalos'' (beau) signifie quelque chose hors du nom composé, ''hippos'' (cheval) aussi ; mais dans ''Kalippos'', ces parties ne signifient plus rien : le nom propre est une unité signifiante qui n’est pas la somme de ses constituants apparents. Aristote introduit également deux notions dérivées. Les noms indéfinis (''aoriston onoma''), comme « non-homme », ne sont pas des noms à proprement parler, mais ils ont une fonction logique importante (notamment dans la négation et dans certaines opérations de la syllogistique). Les noms fléchis (''ptôseis''), comme « de Philon », « à Philon », formes casuelles du nom propre, ne sont pas non plus des noms au sens strict, parce qu’ils n’entrent pas dans une affirmation ou une négation simple : « est-Philon » est susceptible de vrai ou de faux ; « est-à-Philon » ne l’est pas (il manque quelque chose). ====Le verbe (''rhêma'')==== Le verbe (''rhêma''), examiné au chapitre 3, se distingue du nom par deux caractères spécifiques : (1) Il consignifie le temps : « Santé est un nom, est-en-bonne-santé est un verbe ; car il signifie en plus que c’est un attribut maintenant. »<ref>Aristote, ''De l’interprétation'' 3, 16b6-8.</ref> Le terme « consignifier » (''prossêmainein'') est important : le verbe ne signifie pas le temps comme son objet propre (sinon « hier » serait un verbe), mais il signifie quelque chose ''en plus'' de ce qu’il signifie principalement, et ce supplément est le temps. Ce qu’un verbe signifie principalement, c’est une action ou un état (la santé, le fait de marcher) ; ce qu’il consignifie, c’est l’ancrage temporel de cette action ou de cet état dans le présent. (2) Il est toujours signe d’attribution : « il est toujours signe d’attributs, par exemple de choses qu’on dit d’un sujet ». Le verbe, autrement dit, indique toujours quelque chose qu’on attribue à autre chose ; il a une fonction prédicative inscrite dans sa nature même. Catherine Dalimier note dans ses commentaires à l’édition Flammarion que le terme grec ''rhêma'' ne recouvre pas exactement notre concept de « verbe ». Pour Aristote, « marcher » à l’infinitif est encore un nom (parce qu’il n’a, à lui seul, ni valeur temporelle déterminée, ni valeur d’attribution effective) ; il faut « marche » (présent de l’indicatif) pour avoir un ''rhêma'' au sens strict. C’est pourquoi certains traducteurs renoncent à traduire ''rhêma'' par « verbe » et préfèrent calquer le grec. Aristote distingue, comme pour les noms, des rhèmes indéfinis (« n’est-pas-en-bonne-santé », « n’est-pas-malade ») et des rhèmes fléchis (« était-en-bonne-santé », « sera-en-bonne-santé », formes au passé ou au futur). Les rhèmes indéfinis ne sont pas des verbes proprement dits parce qu’ils peuvent appartenir indifféremment à un être existant ou à un non-existant ; ils manquent de la détermination ontologique du verbe authentique. Les rhèmes fléchis ne sont pas des verbes au sens strict parce qu’ils n’ancrent pas l’attribution dans le présent. ====Le verbe « être » : la copule problématique==== Un cas particulier mérite une attention spéciale : le verbe « être » (''einai''), examiné à la fin du chapitre 3. Aristote affirme que « être ou ne pas être n’est pas un signe de la chose réelle, pas même si tu dis "étant" tout court ; car en soi, ce n’est rien, mais il consignifie une certaine composition qu’on ne peut concevoir sans les composants ».<ref>Aristote, ''De l’interprétation'' 3, 16b22-25.</ref> Cette phrase fait l’objet de débats interprétatifs intenses. Selon la lecture traditionnelle (Waitz, Sisson), Aristote suggérerait que le verbe « être » est purement copulatif, c’est-à-dire vide de signifié conceptuel propre : il n’aurait qu’une fonction grammaticale de liaison entre sujet et prédicat. Selon Ammonius et la lecture néo-platonicienne, « être » aurait une signification propre (la « participation » ou la « privation » de l’étant) mais seulement en seconde place ; en première place, il signale la composition qui constitue la proposition. Cette difficulté est centrale pour deux raisons. Premièrement, elle touche à la nature même de la proposition prédicative : qu’est-ce qui se passe, ontologiquement, quand je dis « Socrate est blanc » ? Le « est » ajoute-t-il quelque chose au monde ou est-il un pur outil syntaxique ? Deuxièmement, elle prépare la doctrine, développée dans la ''Métaphysique'', selon laquelle « être » se dit en plusieurs sens (sens copulatif, sens existentiel, sens véritatif), et selon laquelle ces différents sens correspondent à autant de modalités différentes de l’analyse. ====La proposition déclarative (''logos apophantikos'')==== Aristote distingue, au chapitre 4, plusieurs sortes de discours (''logos''). Tout discours n’est pas susceptible d’être vrai ou faux : la prière, l’ordre, la question, l’exclamation sont des discours signifiants mais qui ne se prêtent pas à l’évaluation alèthique. Seul le discours déclaratif (''logos apophantikos''), celui qui ''manifeste'' (du verbe ''apophainein'') un état de choses, peut être vrai ou faux. Aristote précise que les autres formes de discours relèvent plutôt de la rhétorique ou de la poétique. La proposition déclarative se divise en deux espèces : l’affirmation (''kataphasis'') et la négation (''apophasis''). L’affirmation est « la déclaration de quelque chose au sujet de quelque chose » (''apophansis tinos kata tinos'') ; la négation est « la déclaration de quelque chose séparé de quelque chose » (''apophansis tinos apo tinos'').<ref>Aristote, ''De l’interprétation'' 5, 17a25-26.</ref> Ce qui distingue donc une affirmation d’une négation, ce n’est pas simplement la présence d’une particule négative : c’est l’opération mentale de composition (''synthesis'') ou de division (''diairesis'') qui s’y effectue. Affirmer, c’est ''unir'' mentalement deux termes ; nier, c’est ''séparer'' mentalement deux termes. C’est cette opération qui rend possible la vérité ou la fausseté, comme l’expliquera plus tard la ''Métaphysique'' (Γ, 7, 1011b26-27) : « dire que ce qui est est et que ce qui n’est pas n’est pas, c’est le vrai ; dire que ce qui est n’est pas ou que ce qui n’est pas est, c’est le faux ». La vérité est une ''correspondance'' entre la composition mentale et la composition réelle ; quand l’âme unit ce qui, dans le réel, est uni, elle dit vrai ; quand elle sépare ce qui, dans le réel, est uni, ou unit ce qui, dans le réel, est séparé, elle dit faux. ====La contradiction (''antiphasis'')==== Le chapitre 6 introduit la notion de contradiction, au cœur du traité selon l’interprétation de Whitaker. La contradiction, c’est précisément le couple formé par une affirmation et la négation qui lui correspond, où l’on affirme et nie « la même chose de la même chose », sans homonymie, et avec toutes les précisions nécessaires « pour nous défendre des sophistes ».<ref>Aristote, ''De l’interprétation'' 6, 17a33-37.</ref> Cette définition technique cache une thèse philosophique de fond : pour qu’il y ait débat rationnel, il faut que les énoncés qu’on confronte forment un véritable couple de contradictoires, c’est-à-dire qu’ils portent vraiment sur le même sujet sous le même rapport. Aristote précise les conditions à respecter : il faut affirmer et nier la même chose, du même sujet, au même moment, sous le même rapport, et selon le même sens des termes. Si ces conditions ne sont pas réunies, l’apparente contradiction n’en est pas une véritable, et le débat risque de tomber dans le sophisme. Par exemple, « Socrate est assis » et « Socrate n’est pas assis » ne sont pas réellement contradictoires si l’une concerne hier et l’autre aujourd’hui ; ils ne le sont pas non plus si « assis » est pris dans deux sens différents (assis sur une chaise / assis comme on dit d’un raisonnement bien « assis »). La doctrine de la contradiction n’est pas une simple règle formelle : elle est le fondement de la possibilité même d’une discussion rationnelle, et elle structure aussi bien la dialectique que la science. ====Quantité, qualité et le carré logique==== Le chapitre 7 introduit deux distinctions qui, croisées, donneront naissance au carré logique de la tradition scolastique. La première distinction concerne la quantité des propositions. Une proposition peut être universelle (« tout homme est blanc », « aucun homme n’est blanc »), particulière (« quelque homme est blanc », « quelque homme n’est pas blanc »), singulière (« Callias est blanc »), ou indéterminée (« l’homme est blanc », sans préciser tous, certains, ou tel homme particulier). La seconde distinction concerne la qualité : affirmative ou négative. En croisant les deux distinctions principales (universelle/particulière × affirmative/négative), on obtient quatre formes de propositions catégoriques que la tradition désignera par les voyelles A, E, I, O (désignations qui ne sont pas aristotéliciennes mais scolastiques, dérivées des verbes latins ''Affirmo'' et ''Nego'') : A pour l’universelle affirmative (« tout homme est mortel ») ; E pour l’universelle négative (« aucun homme n’est immortel ») ; I pour la particulière affirmative (« quelque homme est juste ») ; O pour la particulière négative (« quelque homme n’est pas juste »). Aristote établit, au chapitre 7, les relations qui existent entre ces formes. A et O sont contradictoires : elles ne peuvent être simultanément vraies ni simultanément fausses. E et I sont également contradictoires. A et E sont contraires : elles ne peuvent être simultanément vraies, mais elles peuvent être simultanément fausses (« tout homme est juste » et « aucun homme n’est juste » sont peut-être toutes deux fausses). I et O sont sub-contraires : elles ne peuvent être simultanément fausses, mais elles peuvent être simultanément vraies. Enfin, A entraîne I (et E entraîne O) : c’est la subalternation. Si tous les hommes sont mortels, alors quelque homme est mortel. Ce dispositif, qui paraît élémentaire, est la base de toute la logique des propositions catégoriques et préfigure une bonne partie de la syllogistique des ''Premiers Analytiques''. ====Les futurs contingents : le chapitre 9==== Le chapitre 9 du ''De interpretatione'' est, comme l’écrit Catherine Dalimier, l’objet de spéculations qui mobilisent depuis l’Antiquité « des générations de logiciens ». Il pose l’une des questions les plus profondes de la logique modale : le principe de bivalence (toute proposition est vraie ou fausse) s’applique-t-il aux propositions portant sur des événements futurs contingents ? Le problème se formule avec une grande clarté. Considérons deux propositions contradictoires : « Il y aura demain une bataille navale » et « Il n’y aura pas demain une bataille navale ». D’après le principe du tiers exclu, l’une de ces deux propositions est vraie et l’autre est fausse. Or, si l’une d’elles est ''dès maintenant'' vraie, alors l’événement qu’elle décrit est ''dès maintenant'' déterminé : il ''doit'' se produire (ou ne pas se produire) demain. Mais cela conduit à un fatalisme logique : tout ce qui doit arriver est déjà fixé, la délibération humaine est illusoire, le hasard et la liberté sont supprimés. Or il est manifeste, pour Aristote, que certains événements à venir sont contingents (ils peuvent se produire comme ne pas se produire) et que la délibération a un sens. L’''Aristoteles-Handbuch'' résume avec précision la structure de l’argument fataliste. La prémisse en est une version ''temporalisée'' du principe de bivalence : tout énoncé est, ''à tout moment'', soit vrai soit faux ; donc, dès aujourd’hui, l’énoncé « il y aura demain une bataille navale » est vrai ou faux. Si cet énoncé est dès aujourd’hui vrai, alors sa vérité est déjà ''advenue'' aujourd’hui ; or ce qui est advenu est immuable (« nécessaire » en un certain sens) ; donc il est nécessaire que demain ait lieu une bataille navale. Le raisonnement, généralisé à tous les énoncés sur l’avenir, conduit à la nécessité de tout ce qui doit arriver, c’est-à-dire au déterminisme intégral. La solution d’Aristote est, comme le note Dalimier, subtile. Il maintient le principe de bivalence pour les propositions portant sur le présent et le passé : ce qui est, est ; ce qui n’est pas, n’est pas ; le passé est immuable. Mais pour les futurs contingents, il propose une distinction délicate : « il est nécessaire que l’affirmation ou la négation soit vraie, mais non que celle-ci déterminément ou celle-là déterminément soit vraie ».<ref>Aristote, ''De l’interprétation'' 9, 19a36-39.</ref> La disjonction « il y aura ou il n’y aura pas demain une bataille navale » est nécessairement vraie (parce que l’un des deux disjoints doit être vrai) ; mais aucun des disjoints pris séparément n’est encore ''déterminément'' vrai ou faux. Selon l’interprétation classique, cette thèse revient à ''suspendre'' l’application déterminée de la bivalence aux futurs contingents, sans pour autant abandonner le principe du tiers exclu pour leur disjonction. L’interprétation de cette doctrine reste discutée. Certains commentateurs (Hintikka) ont contesté que le chapitre 9 ait pour objet de réfuter le fatalisme et ont vu dans Aristote un partisan d’un certain principe de plénitude (selon lequel tout ce qui est possible doit s’actualiser à un moment ou à un autre). D’autres (Crivelli, Weidemann) maintiennent l’interprétation classique : Aristote rejette la version temporalisée de la bivalence pour les énoncés sur les contingents futurs. La logique formelle contemporaine a élaboré, à partir de cette discussion, des logiques plurivalentes (Łukasiewicz) et des logiques temporelles (Prior) qui formalisent cette thèse de la suspension de la bivalence pour le futur. L’enjeu philosophique est important : la doctrine aristotélicienne des futurs contingents prépare une articulation entre logique, métaphysique de la causalité, et philosophie de l’action. Elle préserve la possibilité du hasard, de la délibération, et de la liberté humaine, sans pour autant renoncer aux principes de la rationalité. ====Les propositions modales==== Les chapitres 12 et 13 du ''De interpretatione'' examinent les propositions modales, c’est-à-dire les propositions qui ne se contentent pas d’affirmer ou de nier qu’un prédicat appartient à un sujet, mais qui qualifient cette appartenance comme nécessaire, possible, contingente ou impossible. Aristote distingue quatre modalités fondamentales. Le ''possible'' (''dynaton'', ''endechomenon'') désigne ce qui peut être ; l’''impossible'' (''adynaton''), ce qui ne peut pas être ; le ''contingent'', ce qui peut être ou ne pas être (possibilité bilatérale, dont on dira plus loin un mot) ; le ''nécessaire'' (''anankaion''), ce qui ne peut pas ne pas être. L’analyse aristotélicienne des modalités présente plusieurs particularités sur lesquelles Richard Patterson a particulièrement insisté.<ref>Patterson, R., ''Aristotle’s Modal Logic: Essence and Entailment in the Organon'', Cambridge, Cambridge University Press, 1995.</ref> La première est la distinction entre ''possibilité unilatérale'' et ''possibilité bilatérale'' (ou contingence). Quand je dis « il est possible que S soit P » au sens unilatéral, je veux dire seulement que S peut être P, sans exclure que S soit nécessairement P (la possibilité unilatérale est compatible avec la nécessité). Quand je dis « il est possible que S soit P » au sens bilatéral (la contingence proprement dite), je veux dire à la fois que S peut être P ''et'' que S peut ne pas être P : la chose est possible mais pas nécessaire, elle peut tomber d’un côté ou de l’autre. Cette distinction est cruciale parce qu’elle commande des règles d’inférence et de conversion différentes : « Tout S peut être P » au sens unilatéral n’a pas la même force logique que « Tout S peut être P » au sens bilatéral, et certains syllogismes valides dans un cas ne le sont plus dans l’autre. Aristote observe également que les propositions modales ne se convertissent pas comme les propositions assertoriques. Une proposition assertorique de type I (« quelque S est P ») se convertit simplement en « quelque P est S ». Mais une proposition modale du type « il est possible que quelque S soit P » ne se convertit pas toujours, ou ne se convertit qu’à condition de spécifier le mode de possibilité. Aristote consacre de longs développements, dans les ''Premiers Analytiques'', à examiner ces règles particulières. Patterson met également en évidence la dimension essentialiste de la logique modale aristotélicienne. Quand Aristote dit qu’un énoncé est nécessaire, il ne pense pas, comme la logique modale contemporaine, à un opérateur abstrait s’appliquant à une proposition entière (« il est nécessaire que P »). Il pense à une relation entre des termes qui exprime une connexion essentielle. « Il est nécessaire que tout homme soit animal » ne signifie pas seulement que dans tous les mondes possibles, tout homme est animal ; cela signifie qu’« être animal » fait partie de l’''essence'' de « être homme », et que cette appartenance à l’essence rend la prédication nécessaire. La nécessité est ancrée dans la nature même des choses, dans leur ''quiddité''. Cette caractéristique rend délicate la transposition de la logique modale aristotélicienne dans les langages formels modernes, qui utilisent des opérateurs propositionnels faisant abstraction de la structure essentielle des termes. Les propositions modales jouent un rôle important dans la théorie de la science, comme l’expliqueront les ''Seconds Analytiques'' : la science porte sur le nécessaire, non sur le contingent. Une démonstration scientifique part de prémisses nécessaires et conclut à des nécessités, parce qu’elle vise des vérités universelles et invariables. La théorie modale s’articule ainsi à l’épistémologie aristotélicienne, et les chapitres 12-13 du ''De interpretatione'' préparent les développements plus techniques que livrera Aristote dans la syllogistique modale des ''Premiers Analytiques''. ====Portée philosophique du ''De interpretatione''==== L’importance du ''De interpretatione'' dépasse son objet apparent. Catherine Dalimier souligne, en conclusion de son introduction, que le traité dessine le cadre logique de toute science possible pour Aristote. En établissant que la vérité réside dans la composition et la division opérées par l’intellect, et non dans les choses isolées ni dans les concepts isolés, Aristote fait du jugement le lieu propre de la manifestation de l’être. Cette doctrine sera reprise et développée par toute la philosophie occidentale jusqu’à Kant (qui en fera la base de sa logique transcendantale) et au-delà. Le traité a également une portée pour la philosophie du langage. La distinction entre nom et verbe, l’analyse du caractère conventionnel du signe linguistique, la théorie de la signification et de la référence, la doctrine des futurs contingents : autant de thèmes qui ont nourri la philosophie analytique contemporaine, comme l’a montré Suzanne Husson dans ''Interpréter le De Interpretatione''.<ref>Husson, S. (éd.), ''Interpréter le'' De Interpretatione, Paris, Vrin, 2009.</ref> ====Ce qu’il faut retenir==== Le ''De interpretatione'' fixe l’unité minimale du jugement, la proposition déclarative, et en analyse les composants (nom, verbe, copule). Il définit la contradiction comme couple affirmation/négation et établit les quatre formes catégoriques A, E, I, O. Il pose deux problèmes qui occuperont la postérité : celui des futurs contingents (suspension de la bivalence pour les énoncés sur l’avenir) et celui des modalités (nécessaire, possible, contingent, impossible), avec des règles de conversion propres. La théorie aristotélicienne y montre son caractère sémantique, ancré dans une métaphysique des essences plutôt que dans un formalisme abstrait. ===Chapitre III. ''Les Premiers Analytiques'' : la théorie formelle du syllogisme=== ====Le projet des ''Premiers Analytiques''==== Les ''Premiers Analytiques'' (''Analytica priora'') exposent la théorie du syllogisme dans sa généralité. Leur objet, comme l’explique Aristote dès la première phrase du livre I, est la ''démonstration'', et plus particulièrement le ''syllogisme'' en tant qu’instrument de toute démonstration. Ce qui distingue les ''Premiers'' des ''Seconds Analytiques'', c’est que l’analyse y porte sur la ''forme'' du syllogisme indépendamment de la vérité de ses prémisses. Un syllogisme valide est un syllogisme dont la conclusion suit nécessairement des prémisses, ''que ces prémisses soient vraies ou fausses''. La théorie est donc, en un certain sens, formelle : elle s’intéresse à la structure du raisonnement, non à son contenu particulier. Comme l’observe Pellegrin, cette caractérisation des ''Premiers Analytiques'' comme étude formelle ne doit pas être prise au sens contemporain, où la formalité signifie généralement la possibilité de substituer n’importe quel contenu à une structure syntaxique. Pour Aristote, le formalisme est plus mesuré : il s’agit de dégager les règles qui permettent de dire qu’un raisonnement ''conclut'' à partir de ses prémisses, et ces règles présupposent encore une certaine sémantique des termes. ====La définition du syllogisme==== Aristote définit le syllogisme (''syllogismos''), au chapitre 1 du livre I, dans une formule devenue célèbre : « un discours dans lequel, certaines choses étant posées, quelque chose d’autre que ces données en résulte nécessairement par le seul fait de ces données ».<ref>Aristote, ''Premiers Analytiques'' I, 1, 24b18-20.</ref> Cette définition condense plusieurs conditions qu’il faut prendre soin de distinguer. Première condition : pluralité des prémisses. Un syllogisme suppose au moins deux propositions posées (les prémisses). Aristote ne considère pas comme syllogisme l’inférence à partir d’une seule prémisse. Deuxième condition : la conclusion doit être ''autre chose'' que les prémisses (« quelque chose d’autre que ces données »). Cette clause exclut, en principe, les raisonnements purement tautologiques. Troisième condition : la conclusion doit suivre nécessairement. La nécessité du syllogisme est essentielle : il ne suffit pas que la conclusion soit en fait vraie quand les prémisses sont vraies, il faut qu’elle ne ''puisse pas'' ne pas être vraie quand les prémisses le sont. Quatrième condition, la plus subtile, mise en lumière par Crivelli : la conclusion doit suivre « par le seul fait de ces données » (''tô tauta einai''). Cette clause ajoute à la nécessité une exigence supplémentaire : non seulement la conclusion suit nécessairement, mais elle suit ''en vertu'' des prémisses elles-mêmes, et non d’une autre raison. Crivelli interprète cette clause comme exigeant que le caractère d’avoir la conclusion résultant nécessairement des prémisses devrait être partagé par toutes les inférences de la même structure, ou « forme ». C’est une exigence de ''formalité'' : le syllogisme conclut non pas accidentellement (parce qu’il se trouve, par hasard, que ces prémisses-là ont cette conclusion-là), mais en vertu de la structure formelle du raisonnement. Cette clause permet à Aristote de distinguer le syllogisme d’autres formes d’inférence nécessaire. Comme le rappelle Orna Harari, Aristote affirme explicitement, en ''Premiers Analytiques'' I, 32 (47a31-35) : « tout syllogisme est nécessaire, mais tout ce qui suit nécessairement n’est pas un syllogisme ».<ref>Harari, O., ''Knowledge and Demonstration: Aristotle’s Posterior Analytics'', Dordrecht, Springer / Kluwer, 2004, chap. 3.</ref> Il existe des inférences valides (par exemple les déductions hypothétiques, ou les raisonnements ''par l’absurde'') qui ne sont pas des syllogismes parce qu’elles ne respectent pas la structure formelle requise. ====Termes, prémisses et structure du syllogisme==== Le syllogisme aristotélicien possède une structure rigoureusement délimitée. Il comprend trois termes : le grand terme (ou ''majeur''), le petit terme (ou ''mineur''), et le moyen terme (''mesos'') qui assure la liaison entre les deux extrêmes. Il comporte deux prémisses : la ''majeure'' (qui contient le grand terme) et la ''mineure'' (qui contient le petit terme). Chaque prémisse contient également le moyen terme. Il aboutit enfin à une conclusion qui relie le petit terme au grand terme, sans contenir le moyen terme. L’exemple le plus connu, repris par toute la tradition scolastique, est celui qui prend Socrate comme petit terme : « Tout homme est mortel ; or Socrate est un homme ; donc Socrate est mortel ». Il faut noter qu’il s’agit là d’un exemple à terme singulier, scolaire et postérieur à Aristote ; la syllogistique aristotélicienne traite d’abord des propositions catégoriques entre termes généraux. Une formulation plus conforme à l’analyse aristotélicienne serait : « Tout animal est mortel ; tout homme est animal ; donc tout homme est mortel », où les trois termes (animal, homme, mortel) sont tous généraux et se prêtent aux quantifications universelles. Le moyen terme joue, dans cette structure, un rôle central. Il est ce ''par quoi'' la conclusion est établie. Comme le résume Pellegrin, l’analytique tout entière peut se comprendre comme une technique de recherche du moyen terme : trouver des termes qui permettent de relier deux extrêmes de manière universelle et nécessaire. ====Les trois figures du syllogisme==== Aristote distingue, selon la position du moyen terme dans les prémisses, trois figures syllogistiques. Dans la première figure, le moyen terme est sujet dans la majeure et prédicat dans la mineure : « M est P ; S est M ; donc S est P ». Exemple (Barbara) : « Tout animal est mortel ; tout homme est animal ; donc tout homme est mortel ». Dans la deuxième figure, le moyen terme est prédicat dans les deux prémisses : « P est M ; S est M » (avec l’une des deux prémisses négative). Exemple (Cesare) : « Aucune pierre n’est animée ; tout cheval est animé ; donc aucun cheval n’est une pierre ». Dans la troisième figure, le moyen terme est sujet dans les deux prémisses : « M est P ; M est S ». Exemple (Darapti) : « Tout homme est rationnel ; tout homme est animal ; donc quelque animal est rationnel ». Aristote considère la première figure comme la plus parfaite, et il faut bien comprendre pourquoi. Une figure est dite « parfaite » quand la nécessité de la conclusion est immédiatement évidente, sans qu’il soit besoin de justifier cette nécessité par un détour. Or la première figure exprime de manière directe le principe que la tradition scolastique appellera le ''dictum de omni et nullo'' (« le dit du tout et du rien ») : ce qui est affirmé (ou nié) de tout un genre est affirmé (ou nié) de tout ce qui tombe sous ce genre. Si « tout animal est mortel » et « tout homme est un animal », alors on voit immédiatement que « tout homme est mortel », parce que les hommes, étant des animaux, héritent de tout ce qu’on dit de tous les animaux. Le moyen terme « animal », en se trouvant entre le grand terme (mortel) et le petit terme (homme), permet à la propriété « mortel » de descendre par transitivité jusqu’aux hommes. Les syllogismes des deuxième et troisième figures, en revanche, sont qualifiés d’« imparfaits » non parce qu’ils seraient invalides, mais parce que leur validité ne se voit pas immédiatement : on doit la ''dériver''. Pour cela, Aristote les ramène, par diverses opérations (notamment la conversion des prémisses), à des syllogismes parfaits de la première figure. Démontrer la validité d’un syllogisme de la deuxième figure, c’est montrer qu’il peut être traduit en un syllogisme équivalent de la première figure, où la nécessité de la conclusion est immédiatement saisie. ====Les modes valables : Barbara, Celarent, Darii, Ferio==== À l’intérieur de chaque figure, Aristote détermine les modes qui permettent de conclure validement, en croisant les quantités (universelle/particulière) et les qualités (affirmative/négative) des prémisses. La tradition scolastique a inventé des noms mnémotechniques (Barbara, Celarent, Darii, Ferio pour la première figure ; Cesare, Camestres, Festino, Baroco pour la deuxième ; Darapti, Felapton, Disamis, Datisi, Bocardo, Ferison pour la troisième), où les voyelles indiquent les types des prémisses et de la conclusion (A, E, I, O). Pour la première figure, les quatre modes valables sont : Barbara (AAA), « Tout M est P ; tout S est M ; donc tout S est P » ; Celarent (EAE), « Aucun M n’est P ; tout S est M ; donc aucun S n’est P » ; Darii (AII), « Tout M est P ; quelque S est M ; donc quelque S est P » ; et Ferio (EIO), « Aucun M n’est P ; quelque S est M ; donc quelque S n’est pas P ». Aristote considère également les autres combinaisons et montre lesquelles ne concluent pas (par la méthode des contre-exemples : on trouve une combinaison de termes qui rend les prémisses vraies et la conclusion fausse). ====La conversion et l’ekthèse==== Pour ramener les modes des deuxième et troisième figures à la première, Aristote emploie principalement deux techniques. La conversion (''antistrophê'') consiste à transformer une prémisse en interchangeant son sujet et son prédicat. Aristote établit, au début du livre I, des règles de conversion. La E (universelle négative) se convertit simplement : « Aucun S n’est P » se convertit en « Aucun P n’est S ». La I (particulière affirmative) se convertit simplement aussi : « Quelque S est P » se convertit en « Quelque P est S ». La A (universelle affirmative) se convertit ''par accident'' : « Tout S est P » se convertit en « Quelque P est S » (mais non en « Tout P est S »). La O (particulière négative), enfin, ne se convertit pas. Ces règles permettent de transformer un syllogisme imparfait en un syllogisme parfait de la première figure, et donc de prouver sa validité. L’''ekthèse'' (''ekthesis'', mot grec qui signifie littéralement « exposition », au sens d’« exposer », « mettre en avant ») est une technique plus rare, utilisée notamment pour valider Baroco et Bocardo, deux modes que la conversion ne suffit pas à ramener à la première figure. Le procédé consiste à introduire un terme supplémentaire, plus restreint, qui ''exemplifie'' la condition exprimée par une prémisse particulière. Si l’on a, par exemple, « quelque S n’est pas P », l’ekthèse pose un terme N qui désigne précisément cette portion de S qui n’est pas P, puis raisonne sur N comme sur un nouveau sujet. Le syllogisme se trouve ainsi reformulé sous une forme qui se prête à un raisonnement direct. Comme l’a montré Jonathan Lear, l’ekthèse n’implique pas une « imagination » au sens moderne, ni un appel à l’intuition sensible : c’est une procédure logique rigoureuse qui pose un cas particulier auquel s’appliquent les conditions de la prémisse, et qui permet de rendre manifeste une nécessité qui était implicitement contenue dans les prémisses originales.<ref>Lear, J., ''Aristotle and Logical Theory'', Cambridge, Cambridge University Press, 1980, chap. 1.</ref> Sa parenté avec certaines techniques de preuve mathématique (raisonner sur un cas particulier représentatif) est manifeste. ====La syllogistique modale (Patterson)==== Aux chapitres 3 et 8 à 22 du livre I, Aristote étend la syllogistique aux propositions modales (nécessaires, possibles, contingentes). Cette extension a été l’un des grands chantiers de l’aristotélisme contemporain, et Richard Patterson a proposé une lecture qui permet de comprendre la cohérence d’un système qui a souvent paru aux interprètes (Łukasiewicz, McCall, Hintikka) traversé de contradictions internes. La thèse centrale de Patterson est qu’Aristote fait usage de ce qu’il appelle un ''modal copula'' : la modalité ne s’applique pas à la proposition entière (''de dicto'' : « il est nécessaire que tout homme soit animal »), ni au seul prédicat (''de re'' : « tout homme est nécessairement-animal »), mais à la copule elle-même qui relie sujet et prédicat. Au lieu de dire « tout homme ''est'' animal » (forme assertorique), on dirait « tout homme ''est-nécessairement'' animal » : c’est le « est » lui-même, la liaison prédicative, qui est qualifié modalement. Cette structure est elle-même ambiguë entre deux lectures : une lecture forte (''strong cop'') et une lecture faible (''weak cop''). Dans la lecture forte, la proposition « Animal Ns (nécessaire selon copule forte) Tout-Homme » signifie qu’il existe une connexion ''per se'', c’est-à-dire essentielle, entre les termes : « animal » entre dans la définition d’« homme », c’est sa nature même d’être animal. Les propositions de la science démonstrative, telles que les ''Seconds Analytiques'' les caractérisent, sont des propositions à ''strong cop necessity''. Dans la lecture faible, « Animal Nw Tout-Blanc » par exemple signifie seulement que tout ce qui est blanc est nécessairement animal, non pas parce qu’il y aurait une connexion essentielle entre blancheur et animalité, mais parce que ''de fait'', dans le monde considéré, tout ce qui est blanc se trouve être animal. Cette lecture ''de re'' étendue est plus large : elle inclut des cas où le prédicat appartient nécessairement au sujet sans pour autant entrer dans son essence. Patterson montre que cette dualité éclaire une foule de difficultés que Łukasiewicz avait considérées comme des incohérences. La validité des syllogismes modaux dépend de la lecture qu’on adopte : certains modes sont valides en lecture forte mais non en lecture faible, et inversement. La syllogistique modale aristotélicienne n’est pas un système formel inconsistant ; c’est un système ancré dans une métaphysique essentialiste où la distinction entre essence et accident détermine les règles d’inférence valides. Cette interprétation a également l’intérêt de montrer comment les ''Premiers Analytiques'' préparent les ''Seconds Analytiques''. Les syllogismes scientifiques de la démonstration sont ''par excellence'' des syllogismes modaux à ''strong cop necessity'', où les termes sont liés par des connexions ''per se''. La logique modale n’est pas une curiosité périphérique chez Aristote : elle est l’instrument logique de sa théorie de la science. ====Syllogisme et déduction : une équivalence problématique==== Un point souvent débattu, sur lequel Harari et Crivelli ont apporté des éclairages utiles, concerne la prétention d’Aristote à identifier syllogisme et déduction. Aristote affirme, en ''Premiers Analytiques'' I, 23 (40b20), que tous les syllogismes se forment dans les trois figures, et il ajoute que toute déduction est en principe réductible à un syllogisme. Cette thèse, que Lear appelle « la thèse d’Aristote », est plus forte qu’il n’y paraît. D’un côté, Aristote reconnaît certaines exceptions : les déductions hypothétiques (« si A alors B ; or A ; donc B ») et les raisonnements ''par l’absurde'' ne se ramènent pas à la forme syllogistique standard. D’un autre côté, il maintient que toute ''démonstration scientifique'' directe, dans la mesure où elle vise à établir une connexion essentielle entre des termes, peut être recoulée dans une forme syllogistique. Comme le montre Harari, la logique syllogistique aristotélicienne n’est pas une logique des propositions au sens contemporain (où les prémisses peuvent être de structures variées), mais une logique des prédications. Elle suppose que toute proposition se laisse analyser en sujet-prédicat (« A appartient à B », « A n’appartient pas à B », etc.), et que la combinaison des termes via un moyen terme est l’opération fondamentale du raisonnement valide. C’est précisément cette restriction qui fait sa puissance et son intérêt, mais aussi ses limites par rapport aux logiques modernes plus expressives. ====Ce qu’il faut retenir==== Les ''Premiers Analytiques'' fournissent la théorie générale du syllogisme. Aristote en donne la définition technique (trois termes, deux prémisses, conclusion nécessaire), distingue trois figures (selon la position du moyen terme), identifie les modes valides à l’intérieur de chacune, et propose des techniques (conversion, ekthèse) pour ramener les figures imparfaites à la première, considérée comme parfaite parce qu’elle exprime directement le principe ''dictum de omni et nullo''. La syllogistique modale, lue avec Patterson, révèle une logique ancrée dans une métaphysique essentialiste. Aristote prétend identifier syllogisme et déduction, mais il reconnaît des inférences valides qui ne sont pas des syllogismes (déductions hypothétiques, raisonnements par l’absurde). ===Chapitre IV. ''Les Seconds Analytiques'' : la science démonstrative=== ====Le projet des ''Seconds Analytiques'' et la définition de la science==== Les ''Seconds Analytiques'' (''Analytica posteriora'') prolongent les ''Premiers Analytiques'' mais opèrent un déplacement important. Là où les ''Premiers'' étudiaient le syllogisme dans sa forme générale, les ''Seconds'' s’attachent à une espèce particulière du syllogisme : le syllogisme démonstratif ou scientifique (''apodeixis''). C’est cette espèce qui produit la science, c’est-à-dire la connaissance véritable. Aristote ouvre le traité, au chapitre 2 du livre I, par une définition triple de la science : « Nous estimons posséder la science d’une chose d’une manière absolue quand nous croyons que nous connaissons la cause par laquelle la chose est, que nous savons que cette cause est celle de la chose, et qu’en outre il n’est pas possible que la chose soit autre qu’elle n’est ».<ref>Aristote, ''Seconds Analytiques'' I, 2, 71b9-12.</ref> Cette définition condense trois exigences. Premièrement, la science est connaissance ''par les causes''. Savoir qu’il pleut n’est pas avoir la science de la pluie ; savoir ''pourquoi'' il pleut, ''par quelle cause'', c’est avoir la science. Deuxièmement, cette connaissance doit être ''certaine'' : il faut savoir que la cause est ''bien'' la cause, et non se contenter d’opinions plausibles. Troisièmement, elle porte sur des nécessités : ce qui est connu scientifiquement « ne peut pas être autrement ». La science ne porte pas sur le contingent (qui peut être ou ne pas être), mais sur ce qui est nécessairement ce qu’il est. Orna Harari soulève une question importante quant au sens du terme ''epistêmê''. La traduction traditionnelle par « science » ou « connaissance » a été contestée : certains commentateurs contemporains soutiennent que le sens d’''epistêmê'' chez Aristote serait plus proche de l’''understanding'' (compréhension) que de la ''knowledge'' (connaissance) au sens moderne, parce qu’Aristote serait moins préoccupé par le problème de la justification que par celui de la saisie des structures explicatives. Harari elle-même propose de distinguer deux types de compréhension chez Aristote : une compréhension perceptuelle, liée à l’expérience sensible, et une compréhension conceptuelle, qui saisit les essences universelles. La science aristotélicienne intègre ces deux dimensions : elle part de la perception pour aboutir à la saisie de structures essentielles. ====La démonstration : structure et conditions des prémisses==== La démonstration est, selon Aristote, le syllogisme qui produit la science. Mais tout syllogisme valide n’est pas une démonstration : il faut, pour cela, que les prémisses satisfassent à des exigences supplémentaires. Aristote en énumère six, dans le chapitre 2 du livre I (71b20-25). Les prémisses de la démonstration doivent être vraies (une démonstration ne peut produire la science si elle part du faux : ce serait une démonstration apparente, non réelle). Elles doivent être premières (''prôta'') et immédiates (''amesa'') : elles ne dérivent pas elles-mêmes d’une démonstration, et il n’y a pas de moyen terme entre leur sujet et leur prédicat. Ce sont les principes (''archai'') à partir desquels toute démonstration procède. Elles doivent être plus connues que la conclusion : la connaissance de la conclusion repose sur la connaissance des prémisses ; il faut donc que celles-ci soient mieux connues, faute de quoi nous expliquerions l’obscur par le plus obscur. Elles doivent être antérieures à la conclusion (antériorité non chronologique mais ontologique et explicative : les prémisses sont ce qui rend la conclusion vraie, ce qui la fonde). Elles doivent être causes de la conclusion (''aitia tou symperasmatos'') : la démonstration ne se contente pas de prouver que quelque chose est ; elle explique ''pourquoi'' c’est. Cette exigence, qui distingue la démonstration scientifique du simple syllogisme valide, est ce qu’Aristote oppose au « syllogisme du fait » (''syllogismos tou hoti'') : un syllogisme du fait peut prouver qu’un état de choses existe, mais s’il ne donne pas la cause, il n’est pas un syllogisme du ''pourquoi'' (''syllogismos tou dioti''), et il n’est pas démonstration. Elles doivent être enfin mieux connaissables par nature, même si elles peuvent être moins connues par nous. La science consiste à passer de ce qui nous est plus familier (les phénomènes sensibles, les conclusions immédiates) à ce qui est plus connaissable en soi (les principes universels, les essences). Comme le montre David Bronstein, ces conditions sont liées entre elles : c’est parce que les prémisses sont causes de la conclusion qu’elles lui sont antérieures, et c’est parce qu’elles sont premières qu’elles sont mieux connaissables par nature.<ref>Bronstein, D., ''Aristotle on Knowledge and Learning: The Posterior Analytics'', Oxford, Oxford University Press, 2016.</ref> ====Le rôle du moyen terme : prouver le pourquoi==== Le moyen terme joue, dans la démonstration scientifique, un rôle qui dépasse celui qu’il a dans le syllogisme général. Aristote affirme, en ''Seconds Analytiques'' II, 2 (90a6) : « le moyen terme est la cause ». Cette identification a des implications importantes. L’exemple classique qu’Aristote donne, dans le livre II, est celui de l’éclipse de la lune. Si l’on cherche à ''démontrer'' que la lune subit des éclipses, on construit un syllogisme : tout ce qui est privé de lumière par interposition de la Terre subit une éclipse ; la lune est privée de lumière par interposition de la Terre ; donc la lune subit des éclipses. Le moyen terme (« être privé de lumière par interposition de la Terre ») n’est pas seulement un ''lien logique'' qui permet de relier le sujet (la lune) au prédicat (subir une éclipse). Il est la cause physique réelle de l’éclipse. C’est l’interposition de la Terre qui ''fait'' qu’il y a éclipse ; le syllogisme, pour être démonstratif, doit identifier cette cause et la mettre dans le moyen terme. Pellegrin a souligné, dans son étude des ''Seconds Analytiques'', que connaître scientifiquement les choses, c’est les connaître sous la forme ou dans l’ordre où elles se trouvent dans la démonstration. La démonstration n’est pas une procédure heuristique pour découvrir des explications nouvelles ; elle est plutôt la forme canonique du savoir scientifique, où les relations causales sont déployées de la manière la plus claire et la plus explicite. Aristote distingue, en ''Seconds Analytiques'' II, 11, quatre types de causes correspondant aux quatre causes de la ''Physique'' (II, 3) : la cause matérielle, la cause formelle, la cause efficiente (ou motrice) et la cause finale. La démonstration scientifique peut faire intervenir l’une ou l’autre de ces causes selon la nature de l’explication recherchée. Pellegrin a consacré un article remarqué à l’analyse de ce chapitre, soulignant les difficultés particulières que pose la démonstration de la cause finale, où le syllogisme doit faire intervenir un ''en vue de quoi'' qui ne se laisse pas réduire à un simple lien logique.<ref>Pellegrin, P., « Causal Explanation and Demonstration in ''Posterior Analytics'' II 11 », dans A. P. Mesquita et R. Santos (dir.), ''New Essays on Aristotle’s Organon'', Routledge, 2024, chap. 7.</ref> ====Les principes : axiomes, hypothèses et définitions==== Toute démonstration présuppose, comme on l’a vu, des principes (''archai'') qui ne peuvent être eux-mêmes démontrés. Aristote rejette à la fois la possibilité d’une régression infinie (qui ferait de chaque prémisse l’objet d’une démonstration ultérieure, sans terme) et celle d’une démonstration circulaire (où A serait démontré par B et B par A). Il faut donc admettre l’existence de principes premiers connus ''sans'' démonstration. Aristote distingue trois catégories de principes. Les axiomes (''axiômata'') ou principes communs sont des principes qui s’appliquent à toutes les sciences. Le plus fondamental est le principe de non-contradiction (« il est impossible que le même attribut appartienne et n’appartienne pas en même temps au même sujet et sous le même rapport », ''Métaphysique'' Γ, 3, 1005b19-20). Vient ensuite le principe du tiers exclu (« entre deux contradictoires, il n’y a pas de moyen terme »). Aristote considère ces axiomes comme des principes que toute pensée rationnelle présuppose nécessairement : les contester reviendrait à contester la possibilité même de penser et de parler. Il défend le principe de non-contradiction, dans le livre Γ de la ''Métaphysique'', par une ''réfutation dialectique'' de ses adversaires : dès lors que celui qui le conteste « dit quelque chose de déterminé », il présuppose le principe qu’il prétend nier. Les hypothèses (''hypotheseis'') et les thèses (''theseis'') sont des principes propres à chaque science. Aristote distingue les hypothèses, qui affirment l’existence de certaines entités (les points en géométrie, les unités en arithmétique), et les thèses, qui prennent position sans nécessairement affirmer une existence. En géométrie, l’hypothèse selon laquelle « il existe des points » est un principe que la science ne démontre pas, mais qu’elle pose comme condition de possibilité de ses démonstrations. Les définitions (''horoi'') sont des énoncés qui disent ce qu’est une chose, son essence (''ti esti''). Aristote consacre une grande partie du livre II des ''Seconds Analytiques'' à examiner si l’essence peut être démontrée. Il conclut négativement : la définition n’est pas une démonstration, parce qu’elle ne procède pas par syllogisme et qu’elle pose plutôt qu’elle ne prouve. Toutefois, certaines définitions peuvent ''découler'' de démonstrations : c’est le cas des définitions causales. La définition du tonnerre, par exemple (« bruit dans les nuages causé par l’extinction du feu »), peut être obtenue à partir d’une démonstration qui établit la cause (l’extinction du feu) du phénomène (le bruit dans les nuages). ====L’unité et la diversité des sciences==== Une thèse importante des ''Seconds Analytiques'' est l’interdiction du passage d’un genre à un autre (''metabasis eis allo genos''). Chaque science se définit par son genre propre (le genre des objets dont elle traite), et elle ne peut emprunter ses principes ou ses démonstrations à une autre science. La géométrie ne peut démontrer une vérité d’arithmétique ; la physique ne peut démontrer une vérité de biologie en utilisant des principes physiques. Cette règle, expliquée en ''Seconds Analytiques'' I, 7, garantit l’autonomie des sciences. Chaque science a ses propres principes, son propre objet, ses propres méthodes adaptées à la nature de son objet. Comme l’écrira Aristote dans l’''Éthique à Nicomaque'' (I, 3) : il faut chercher dans chaque domaine la précision que cet objet permet ; il serait absurde d’exiger d’un mathématicien la rigueur d’un orateur, et tout aussi absurde d’exiger d’un orateur la précision du mathématicien. Aristote admet cependant trois exceptions à cette règle d’autonomie. La première est la subordination d’une science à une autre : l’optique est subordonnée à la géométrie, en ce qu’elle utilise les théorèmes géométriques mais les applique à un objet particulier (la lumière). Il ne s’agit pas d’un transfert illicite, parce que la science subordonnée prend ses principes d’une science supérieure dont elle dépend. La deuxième est le partage des axiomes communs : le principe de non-contradiction n’est pas le monopole d’une science particulière, il est utilisé par toutes. Aristote précise toutefois que chaque science l’utilise « dans la mesure » où son objet l’exige. La troisième est l’existence des sciences mixtes (''metaxu'') : l’astronomie mathématique, la mécanique, l’harmonique sont des sciences qui utilisent les mathématiques pour étudier des phénomènes physiques. Ce sont des cas particuliers où la transition d’un genre à un autre est légitime parce que les phénomènes étudiés ont une structure mathématique intrinsèque. Cette théorie de l’unité-diversité des sciences fonde l’organisation aristotélicienne du savoir. Chaque science possède une méthode adaptée à son objet : les mathématiques procèdent par démonstration nécessaire à partir d’axiomes ; la physique tient compte du changement et des causes naturelles ; la biologie observe les régularités qui se produisent « dans la plupart des cas » (''hôs epi to poly''), formule importante parce qu’elle reconnaît que le vivant n’obéit pas à une nécessité aussi rigoureuse que la matière inerte ; l’éthique et la politique se contentent de vérités qui valent « pour la plupart », parce que leur objet (l’action humaine) est essentiellement contingent. ====La connaissance des principes : induction et intellection==== Si toute science procède de principes indémontrables, comment pouvons-nous connaître ces principes eux-mêmes ? Cette question, posée dès le chapitre 3 du livre I, reçoit sa réponse dans le dernier chapitre des ''Seconds Analytiques'' (II, 19), l’un des textes les plus discutés de l’aristotélisme. La réponse d’Aristote articule plusieurs étapes. La connaissance des principes ne peut pas être innée (comme le voudrait Platon avec sa théorie de la réminiscence), parce qu’il serait absurde que nous possédions une connaissance plus exacte que la science et que nous l’ignorions. Mais elle ne peut pas non plus naître de rien, parce que toute connaissance présuppose une certaine connaissance préalable. La solution est qu’il existe en nous une ''capacité'' (''dynamis'') de connaître les principes, capacité qui n’est pas elle-même une connaissance déterminée mais qui se déploie progressivement à partir de la sensation. Aristote décrit ce processus en plusieurs étapes. La sensation (''aisthêsis'') est la base : elle saisit les choses singulières. La sensation laisse des traces qui constituent la mémoire (''mnêmê'') : nous nous souvenons des choses perçues. L’accumulation et l’organisation des souvenirs produisent l’expérience (''empeiria'') : nous reconnaissons des régularités, des cas semblables. À partir de l’expérience se forme l’universel (''katholou'') qui « se repose dans l’âme » et constitue l’art (''technê'') ou la science (''epistêmê'').<ref>Aristote, ''Seconds Analytiques'' II, 19, 100a3-9.</ref> Aristote compare ce processus, dans une image célèbre, à une armée en déroute : « comme dans une bataille, quand une déroute se produit, un soldat fait halte, puis un autre, puis un autre, jusqu’à ce que se reconstitue le rang originel ». De même, à partir d’une multitude d’impressions sensorielles d’abord confuses, l’âme construit progressivement la saisie de l’universel. Le terme qu’Aristote emploie pour ce processus est ''epagôgê'', qu’on traduit traditionnellement par induction. Mais Orna Harari a montré, dans une analyse minutieuse, que ce terme a chez Aristote (au moins) deux sens distincts. Dans un premier sens, l’induction argumentative, ''epagôgê'' désigne un type d’inférence qui établit (ou justifie) une proposition universelle à partir de cas particuliers. C’est l’usage qu’on trouve dans les ''Topiques'' et dans les ''Premiers Analytiques'' II, 23. Cette induction est une ''forme de raisonnement'' qui peut être confrontée au syllogisme. Dans un second sens, l’induction cognitive ou perceptuelle, ''epagôgê'' désigne un acte cognitif par lequel l’esprit reconnaît dans le particulier l’aspect universel. C’est cet usage qu’on trouve, selon Harari, en ''Seconds Analytiques'' II, 19 et I, 1. Quand Aristote dit que nous saisissons les principes par induction, il ne veut pas dire que nous les ''prouvons'' à partir de cas particuliers ; il veut dire que la perception elle-même, en saisissant un cas particulier, nous ouvre l’accès à l’universel qui s’y manifeste. La saisie ultime des principes premiers relève d’une faculté qu’Aristote nomme intellect (''nous'') ou intuition intellectuelle. C’est, écrit-il en ''Seconds Analytiques'' II, 19 (100b12), « la disposition par laquelle nous connaissons les principes ». Le ''nous'' est, selon Aristote, supérieur même à la science démonstrative, parce qu’il saisit ce qui rend la science possible. Comme le formule l’''Éthique à Nicomaque'' VI, 6 : « l’intellect a pour objet les principes, dont aucune définition ne peut rendre compte ». Cette doctrine soulève des difficultés notoires que les commentateurs ont longuement débattues. Comment l’intellect peut-il être à la fois une ''capacité innée'' (en puissance, depuis la naissance) et le ''résultat d’un apprentissage'' (en acte, à partir de l’expérience) ? Comment articuler le rôle de l’induction (qui est un processus discursif) et celui de l’intellect (qui est, selon l’''Éthique à Nicomaque'', ''non discursif'') ? Harari propose une articulation : l’induction est la ''procédure'' qui prépare la saisie ; l’intellect est l’''état cognitif'' qui résulte de cette préparation et qui ''saisit'' effectivement les principes. Les deux sont nécessaires : l’induction sans intellect est aveugle ; l’intellect sans induction n’a pas de matière à saisir. ====Le statut épistémologique des ''Seconds Analytiques'' : démonstration ou méthode ?==== Une question importante, sur laquelle Pellegrin et d’autres commentateurs (Brunschwig, Bronstein) ont insisté, concerne le statut même de la démonstration aristotélicienne. La démonstration est-elle une méthode de découverte, un instrument heuristique pour trouver des vérités nouvelles ? Ou est-elle plutôt la forme canonique du savoir établi, c’est-à-dire la manière dont une science déjà constituée s’expose et se transmet ? La lecture traditionnelle, dominante depuis l’Antiquité jusqu’à la Renaissance, voyait dans la démonstration une méthode de découverte. La science moderne, à partir de Bacon et de Descartes, a critiqué cette lecture : la démonstration syllogistique, disait Bacon, ne fait que tirer ce qu’on a déjà mis dans les prémisses ; elle ne produit pas de connaissance nouvelle. La lecture contemporaine, esquissée par Pellegrin et défendue par Bronstein, propose une autre interprétation. La démonstration n’est pas une méthode de ''découverte'', mais la ''forme du savoir scientifique constitué''. Une fois qu’une science a identifié ses principes (par induction et intellection) et a établi les connexions causales entre les phénomènes qu’elle étudie, elle peut ''exposer'' ce savoir sous la forme d’une chaîne de syllogismes démonstratifs. La démonstration est l’organisation rationnelle d’un savoir, son architecture logique, et non pas la procédure par laquelle ce savoir a été acquis. Cette interprétation a deux mérites. D’abord, elle dissout l’objection baconienne-cartésienne : si la démonstration n’est pas une méthode de découverte, elle ne peut pas être critiquée pour ne pas en être une. Ensuite, elle restitue l’unité du projet aristotélicien : les ''Topiques'' (méthode dialectique de découverte des prémisses), les ''Premiers Analytiques'' (forme générale du syllogisme) et les ''Seconds Analytiques'' (forme spécifique du syllogisme scientifique) ne sont pas des projets disjoints, mais les pièces d’un dispositif où la dialectique fournit les matériaux que la science organise sous forme démonstrative. ====Ce qu’il faut retenir==== Les ''Seconds Analytiques'' développent la théorie de la science démonstrative. La science est connaissance par les causes, certaine et nécessaire ; la démonstration est le syllogisme qui la produit, à six conditions ; le moyen terme est la cause. Les principes (axiomes, hypothèses, définitions) ne sont pas démontrables, et chaque science a son genre propre (interdiction de la ''metabasis''). La connaissance des principes procède par induction sensorielle et culmine dans l’intellect (''nous''). La démonstration s’entend mieux, selon une lecture contemporaine, comme forme du savoir constitué que comme méthode heuristique de découverte. ===Chapitre V. ''Les Topiques'' : l’art de la dialectique=== ====La dialectique : définition et statut==== Les ''Topiques'' constituent, selon l’hypothèse défendue notamment par Brunschwig, le traité chronologiquement parmi les plus anciens de l’''Organon'' (bien que l’arrangement andronicien l’ait relégué à la fin). Ils sont consacrés à l’art de la dialectique (''dialektikê''), c’est-à-dire à la pratique de la discussion réglée entre deux interlocuteurs. Aristote distingue dès le premier chapitre du livre I trois types de raisonnement selon la nature de leurs prémisses. Le syllogisme démonstratif part de prémisses ''vraies et premières'', ou de prémisses qui sont elles-mêmes connues par des prémisses vraies et premières : c’est l’objet des ''Seconds Analytiques''. Le syllogisme dialectique part de prémisses qui sont des opinions valables (''endoxa''), c’est-à-dire « des opinions qui sont admises par tous, ou par la plupart, ou par les sages, et parmi les sages, par tous, ou par la plupart, ou par les plus connus et les plus reconnus ».<ref>Aristote, ''Topiques'' I, 1, 100a29-b23.</ref> Le syllogisme éristique ou sophistique, enfin, part de prémisses qui ''paraissent'' être des opinions valables sans l’être réellement : c’est l’objet des ''Réfutations sophistiques''. La distinction entre démonstration et dialectique n’est pas une distinction de ''forme'' (les deux sont des syllogismes au sens des ''Premiers Analytiques'') mais de ''matière'' : ce qui les distingue, c’est la nature des prémisses qu’elles utilisent. Cette différence a néanmoins des conséquences importantes. La démonstration vise la connaissance scientifique ; la dialectique vise une argumentation rationnelle dans les domaines où la certitude scientifique n’est pas accessible, c’est-à-dire la grande majorité des domaines de la pensée et de la vie humaine. ====La structure du débat dialectique==== Le débat dialectique aristotélicien possède une structure formellement très précise. Comme l’a montré Brunschwig dans son introduction à l’édition des ''Topiques'' (Belles Lettres), il met en présence deux protagonistes : le questionneur et le répondant. Le répondant (en grec, ''apokrinomenos'') commence par défendre une thèse, c’est-à-dire une proposition à laquelle il s’engage. Cette thèse peut être affirmative ou négative ; le répondant est libre de la choisir. Une fois la thèse posée, son rôle consiste à la ''défendre'' contre les attaques du questionneur, en accordant ou en refusant les prémisses que celui-ci lui propose. Le questionneur (en grec, ''erôtôn'') a la tâche la plus lourde : il doit, écrit Brunschwig, « construire une argumentation formellement contraignante, ayant pour prémisses des propositions auxquelles le répondant ne puisse refuser son assentiment, et pour conclusion la proposition contradictoire de celle que soutient le répondant ».<ref>Brunschwig, J., introduction à Aristote, ''Topiques'' (livres I-IV), Les Belles Lettres, 1967, rééd. 2007, p. XXXVIII.</ref> Autrement dit, le questionneur doit ''réfuter'' la thèse du répondant en tirant, à partir de prémisses que le répondant ne peut refuser, la contradictoire de cette thèse. Cette structure n’est pas un simple jeu : elle est le modèle aristotélicien du débat rationnel. Toute discussion philosophique sérieuse, toute confrontation intellectuelle où l’on cherche à éprouver la solidité d’une opinion, peut être analysée selon ce modèle. Et la fonction des ''Topiques'' est de fournir au questionneur (et secondairement au répondant) un manuel pratique pour mener ce type de discussion avec efficacité. ====Les quatre prédicables==== Avant de présenter les ''topoi'' eux-mêmes, Aristote introduit, au livre I, une distinction importante : celle des quatre prédicables. Quand on attribue un prédicat à un sujet, on peut le faire de quatre manières. Par accident (''sumbebêkos''), le prédicat n’est ni essentiel au sujet ni de même extension que lui : « Socrate est blanc » ; « blanc » est un accident de Socrate, qui peut être remplacé par un autre attribut sans que Socrate cesse d’être Socrate. Par genre (''genos''), le prédicat est essentiel au sujet mais d’une extension plus large que lui : « Socrate est animal » ; « animal » est le genre auquel appartient l’espèce « homme », et Socrate, en tant qu’homme, hérite de tout ce qui appartient à l’animal. Par propre (''idion''), le prédicat n’est pas essentiel au sujet mais lui est coextensif (il s’applique à tout le sujet et seulement à lui) : « L’homme est rieur » ; être rieur n’entre pas dans la définition de l’homme, mais tout homme est rieur et tout rieur est homme. Par définition (''horos''), enfin, le prédicat est à la fois essentiel et coextensif au sujet : « L’homme est animal raisonnable » ; la définition exprime l’essence de manière exhaustive, en composant le genre et la différence spécifique. Cette classification fournit un cadre pour organiser les ''topoi''. Les livres II et III des ''Topiques'' sont consacrés aux topoi de l’accident, le livre IV aux topoi du genre, le livre V à ceux du propre, les livres VI et VII à ceux de la définition. Pour chaque type de prédication, on dispose d’arguments qui permettent soit de l’établir soit de la réfuter. Pour démontrer qu’un prédicat est ''accidentel'' à un sujet, par exemple, on n’utilisera pas les mêmes outils que pour démontrer qu’il en constitue le ''genre'' : la stratégie argumentative dépend du type de relation prédicative qu’on cherche à établir ou à contester. Cette classification aristotélicienne en quatre prédicables sera reprise par Porphyre dans son ''Isagoge'' (introduction aux ''Catégories''), qui en proposera une version à cinq prédicables (genre, espèce, différence, propre, accident). La version à cinq prédicables, qu’on appelle souvent les « cinq voix de Porphyre », a dominé la tradition logique médiévale ; mais le découpage proprement aristotélicien est bien celui à quatre prédicables. ====Les ''topoi'' : qu’est-ce qu’un lieu commun ?==== Le mot grec ''topos'' signifie « lieu », et l’''Aristoteles-Handbuch'' explique l’image : comme un général doit identifier le « lieu » à partir duquel attaquer son adversaire, le dialecticien doit identifier les ''lieux'' d’argumentation à partir desquels il pourra construire ses prémisses. Aristote ne donne nulle part une définition précise du ''topos'', mais on peut le caractériser comme un schéma général d’argumentation sous lequel tombent plusieurs arguments concrets. Un exemple permet de saisir : le topos du plus et du moins (II, 10). Si ce qui est plus susceptible d’appartenir à quelque chose ne lui appartient pas, ce qui l’est moins ne lui appartiendra pas non plus. À partir de ce schéma général, on peut construire une multitude d’arguments concrets : si même la mer ne te paraît pas froide, comment l’eau de la rivière le serait-elle ? si même Socrate, qui est sage, ne sait pas la solution, comment quelqu’un de moins sage la saurait-il ? Le ''topos'' n’est pas un argument particulier, c’est une matrice qui permet d’engendrer des arguments adaptés à chaque cas. Aristote présente, dans les ''Topiques'', plusieurs centaines de ''topoi''. Le projet est moins celui d’une théorie systématique que celui d’un manuel pratique : on y trouve des conseils stratégiques (livre VIII) sur la manière de poser les questions, de cacher ses intentions, d’amener progressivement le répondant à accorder les prémisses dont on a besoin. ====Les trois utilités de la dialectique==== Aristote précise, au chapitre 2 du livre I, à quoi sert la dialectique. Il distingue trois utilités. La première est l’entraînement intellectuel (''gymnasia'') : le débat dialectique est un exercice qui forme l’esprit, comme le sport forme le corps ; il habitue à formuler clairement ses idées, à anticiper les objections, à raisonner rapidement. La deuxième concerne les rencontres avec autrui : pour discuter efficacement avec ceux qui n’ont pas reçu de formation philosophique (« les Plusieurs »), il faut pouvoir argumenter à partir de leurs propres opinions, c’est-à-dire à partir des ''endoxa''. La dialectique fournit les outils pour ce type de discussion. La troisième utilité concerne les sciences philosophiques elles-mêmes ; c’est la plus importante et la plus subtile. Aristote affirme que la dialectique permet d’examiner ''critiquement'' (''peirastikê'') les principes des sciences, en confrontant les opinions opposées. Elle permet aussi de parcourir les difficultés (''aporiai'') et de dégager progressivement les principes véritables. Cette méthode est mise en œuvre par Aristote lui-même dans la quasi-totalité de ses traités : il commence presque toujours par exposer les opinions de ses prédécesseurs (les ''endoxa''), il en montre les insuffisances et les contradictions (les ''aporiai''), et il dégage progressivement sa propre position en cherchant à ''préserver'' ce qui était vrai dans les opinions reçues tout en évitant leurs erreurs. Cette troisième utilité a un poids philosophique particulier. Elle implique que la dialectique n’est pas seulement un instrument de discussion publique, mais un instrument de recherche philosophique. Elle permet d’aborder les principes que la science démonstrative ne peut pas elle-même fonder : comme l’écrit Aristote en ''Topiques'' I, 2, les principes des sciences ne peuvent être tirés des sciences elles-mêmes (puisque les principes sont premiers dans chaque science), mais ce sont seulement les ''endoxa'' qui peuvent y conduire. La dialectique, par son examen critique des opinions reçues, fournit un chemin vers les principes ; chemin qui ne remplace pas l’intuition intellectuelle (''nous'') mais qui la prépare et l’éclaire. L’''Aristoteles-Handbuch'' formule cette idée de la manière suivante : l’argumentation à partir des ''endoxa'', exercée dans la dialectique, ouvre la seule voie vers la consolidation argumentative des principes, quand bien même ceux-ci auraient été d’abord saisis par voie inductive et noétique. La dialectique aristotélicienne n’est pas une simple sous-discipline de la logique : elle est un mode d’accès à la vérité qui complète la démonstration par le bas (en approchant les principes) et la science par le haut (en discutant leurs présupposés). ====Ce qu’il faut retenir==== Les ''Topiques'' codifient l’art du débat dialectique entre questionneur et répondant. Le syllogisme dialectique part de prémisses simplement probables (''endoxa'') et non de prémisses vraies. Aristote distingue quatre prédicables (accident, genre, propre, définition) qui organisent les centaines de ''topoi'' du traité. La dialectique a trois utilités : entraînement intellectuel, discussion avec autrui, recherche philosophique. Cette dernière utilité est philosophiquement importante : elle permet d’approcher les principes que la science démonstrative présuppose sans pouvoir les fonder. ===Chapitre VI. ''Les Réfutations sophistiques'' : les paralogismes et leur démasquage=== ====Le projet des ''Réfutations sophistiques''==== Les ''Réfutations sophistiques'' (''Peri sophistikôn elenchôn'') sont parfois considérées comme un appendice (ou même comme un neuvième livre) des ''Topiques''. Leur projet est complémentaire : alors que les ''Topiques'' enseignent à construire des syllogismes dialectiques ''valides'', les ''Réfutations sophistiques'' étudient les syllogismes qui ''paraissent'' valides sans l’être. Aristote y entreprend une typologie systématique des paralogismes (raisonnements fallacieux) pour permettre au dialecticien de les détecter et de les démasquer. L’''Aristoteles-Handbuch'' précise le projet : il s’agit pour Aristote de fournir une analyse critique de la pratique argumentative des sophistes. Le sophiste est, pour Aristote (à la suite de Platon), celui qui « se donne l’apparence de la sagesse sans l’être réellement » et qui « gagne son argent avec l’apparence de la sagesse ». Pour produire cette apparence, le sophiste utilise des paralogismes, c’est-à-dire des raisonnements qui ressemblent à des syllogismes valides sans en être réellement. ====Les six sophismes liés au langage==== Aristote distingue treize types de sophismes, qu’il classe en deux catégories. Six dépendent du langage (''para tên lexin''), sept en sont indépendants.<ref>Aristote, ''Réfutations sophistiques'' 4, 165b23-166b19. Voir Dorion, L.-A., ''Les Réfutations Sophistiques d’Aristote'', Paris, Vrin, 1995.</ref> Les six sophismes liés au langage sont d’abord l’''homonymie'', qui exploite l’ambiguïté d’un mot ayant plusieurs sens : « Le chien aboie ; or la canicule est un chien (en grec, ''kuôn'' signifie à la fois le chien et la constellation du Chien) ; donc la canicule aboie. » Vient ensuite l’''amphibologie'', qui repose sur l’ambiguïté syntaxique d’une phrase : « Je veux que vous capturiez les ennemis » peut signifier « je veux que ce soit vous qui capturiez les ennemis » ou « je veux que les ennemis soient capturés par vous ». Le sophisme de la ''composition'' consiste à confondre un sens où plusieurs termes sont pris ensemble et un sens où ils sont pris séparément. L’exemple d’Aristote : « Il est possible de se tenir debout pendant qu’on est assis » peut signifier soit qu’il est possible d’être à la fois assis et debout au même moment (sens composé, faux), soit qu’il est possible, pour quelqu’un actuellement assis, de se tenir debout à un autre moment (sens divisé, vrai). Le sophisme de la ''division'' procède à la confusion inverse, en prenant séparément ce qui doit être pris ensemble. Le sophisme de l’''accentuation'' joue sur une ambiguïté qui repose sur la prononciation, l’accent ou la quantité des syllabes. Enfin, le sophisme de la ''forme de l’expression'' confond des expressions qui ont la même forme grammaticale mais relèvent de catégories différentes : on peut par exemple traiter « courir » comme s’il désignait une qualité quand il désigne en réalité une action. ====Les sept sophismes hors du langage==== Les sept sophismes qui ne dépendent pas du langage sont les suivants. Le sophisme de l’''accident'' confond ce qui est attribué à un sujet de manière essentielle et ce qui ne lui est attribué que de manière accidentelle. L’exemple classique d’Aristote : « Coriskos est différent de Socrate ; or Socrate est un homme ; donc Coriskos est différent d’un homme. » Le paralogisme consiste à transférer la différence entre Coriskos et Socrate (en tant qu’individus) à la relation entre Coriskos et l’humanité, comme si le fait que Socrate soit un homme rendait toute différence d’avec Socrate une différence d’avec l’humanité. Le ''passage du qualifié à l’absolu'' (''secundum quid'') confond ce qui est vrai sous un certain rapport et ce qui est vrai absolument : « L’Éthiopien est blanc des dents ; donc l’Éthiopien est blanc. » L’''ignorance de la réfutation'' (''ignoratio elenchi'') consiste à produire une réfutation qui ne réfute pas vraiment la thèse adverse, parce qu’elle ne porte pas exactement sur la même chose, ou ne respecte pas les conditions de la véritable contradiction. La ''pétition de principe'' (''petitio principii'') suppose dans les prémisses ce qu’on prétend démontrer dans la conclusion. Le ''faux enchaînement'' consiste à prendre pour cause ce qui n’est pas la cause. C’est le sophisme du ''non causa pro causa'', particulièrement répandu dans les ''reductio ad impossibile'' mal conduits. La ''prise de l’antécédent comme conséquent'' infère « si A alors B ; or B ; donc A » (qui est formellement invalide). L’''Aristoteles-Handbuch'' en donne un exemple éclairant : si l’interlocuteur a admis que la terre est mouillée, le sophiste en conclut faussement qu’il a plu ; il a tacitement renversé la véritable relation de conséquence (« s’il a plu, la terre est mouillée ») en la pseudo-relation (« si la terre est mouillée, il a plu »). La ''réunion de plusieurs questions en une'', enfin, consiste à poser une question complexe (« Avez-vous cessé de battre votre femme ? ») où une réponse unique implique des concessions sur plusieurs points distincts. ====La réduction à l’''ignoratio elenchi''==== Un point important, souvent négligé, est qu’Aristote affirme, au chapitre 6 des ''Réfutations'', que tous les treize types de sophismes peuvent être ramenés à un seul : l’ignorance de la réfutation (''ignoratio elenchi''). Pour chacune des treize espèces, la cause de la tromperie peut être analysée comme une méconnaissance de ce qu’est une véritable réfutation. Une réfutation authentique exige que le syllogisme conclue à la ''contradictoire exacte'' de la thèse adverse, c’est-à-dire qu’il porte sur le même sujet, sous le même rapport, dans le même sens des termes, etc. Les sophismes manquent toujours, d’une manière ou d’une autre, à l’une de ces conditions : ils produisent une apparente contradiction qui n’est pas une véritable contradiction. C’est pourquoi la définition rigoureuse de la contradiction (que le ''De interpretatione'' avait déjà élaborée) est la clef de la détection de tous les paralogismes. ====Logique et défense de la rationalité==== Au-delà de son utilité technique, l’étude des sophismes a une portée philosophique propre. Elle implique qu’il existe des règles objectives du raisonnement correct, indépendantes de l’habileté rhétorique de celui qui argumente. Cette thèse a été souvent lue, dans la tradition interprétative, comme une réponse au relativisme attribué aux sophistes (relativisme dont Protagoras est le représentant emblématique). Une partie de la recherche contemporaine nuance cependant cette lecture. Comme l’a montré L.-A. Dorion dans son étude sur la dialectique aristotélicienne, la « réponse aux sophistes » n’est pas un combat frontal contre des adversaires identifiés, mais une délimitation du domaine de la science par rapport à celui de l’argumentation persuasive. Aristote ne prétend pas réfuter le sophiste ; il prétend établir les conditions minimales pour qu’un discours rationnel soit possible. Comme dans la défense du principe de non-contradiction au livre Γ de la ''Métaphysique'', il s’agit moins de démontrer une thèse que de montrer que toute pensée présuppose certains principes faute de quoi elle s’effondre dans l’inintelligibilité. C’est en ce sens que les ''Réfutations sophistiques'' ne sont pas un appendice mineur, mais une pièce nécessaire de l’''Organon'' : elles défendent la rationalité elle-même contre ses propres dégradations possibles. La logique aristotélicienne n’est pas seulement une théorie du raisonnement valide ; elle est aussi une éthique du raisonnement, qui distingue la recherche honnête de la vérité de la manipulation rhétorique des esprits. ====Ce qu’il faut retenir==== Les ''Réfutations sophistiques'' classent treize types de paralogismes (six liés au langage, sept indépendants du langage) et les ramènent tous à un seul, l’ignorance de la réfutation. Le traité défend les conditions minimales du discours rationnel et complète, par leur démasquage, la théorie du raisonnement valide exposée dans les autres traités de l’''Organon''. ===Conclusion. Postérité et lectures contemporaines de l’''Organon''=== ====Continuités et transformations==== L’''Organon'' a fourni, pendant deux millénaires, le modèle dominant de la rationalité occidentale. Les ''Éléments'' d’Euclide, avec leur méthode axiomatique-déductive, ont été lus dans la tradition comme un exemple privilégié d’organisation déductive du savoir, en consonance avec l’idéal aristotélicien de science (sans qu’il faille pour autant supposer qu’Euclide ait composé son ouvrage en application directe du programme aristotélicien). La théologie scolastique médiévale, des grands commentateurs arabes (Al-Farabi, Avicenne, Averroès) à Thomas d’Aquin, a fait du syllogisme et de la démonstration les instruments fondamentaux de la pensée rationnelle. Les universités européennes, jusqu’au XVII{{e}} siècle, ont enseigné la logique sur la base des traités aristotéliciens. La révolution scientifique moderne a ébranlé cet édifice. Bacon, dans le ''Novum Organum'' (1620, dont le titre même est un défi à Aristote), dénonce la stérilité de la logique syllogistique : elle ne fait que tirer ce qu’on a déjà mis dans les prémisses, elle ne produit pas de connaissance nouvelle. Descartes, dans le ''Discours de la méthode'', reproche au syllogisme de servir « plutôt à expliquer à autrui les choses qu’on sait, ou même à parler sans jugement de celles qu’on ignore, qu’à les apprendre ». La science moderne, avec son recours à l’expérimentation, à l’hypothèse provisoire, à la méthode hypothético-déductive, semble s’écarter résolument du modèle aristotélicien. Cette rupture, longtemps présentée comme totale, est aujourd’hui nuancée par la recherche contemporaine. Comme le rappelle Pellegrin, la transition vers la science moderne combine continuités, relectures et oppositions plutôt qu’une opposition unilatérale. Les scolastiques tardifs (Zabarella, Pacius) avaient déjà élaboré, à l’intérieur du cadre aristotélicien, des distinctions méthodologiques (entre démonstration ''quia'' et démonstration ''propter quid'', entre méthode résolutive et méthode compositive) qui annoncent les analyses modernes. ====Les apports durables de la logique aristotélicienne==== Quelles que soient les critiques adressées à la logique aristotélicienne, son apport historique reste considérable. Premièrement, Aristote a établi pour la première fois les règles de la déduction valide. Il a distingué clairement la ''forme'' du raisonnement (qui détermine sa validité) de sa ''matière'' (qui détermine la vérité de ses prémisses). Cette distinction, qui paraît aujourd’hui évidente, a constitué un acquis durable de la philosophie. Deuxièmement, Aristote a montré la nécessité des principes premiers. Toute chaîne de justification doit s’arrêter quelque part, faute de quoi elle régresse à l’infini ou se mord la queue. Cette analyse du « trilemme d’Agrippa » avant l’heure est l’une des contributions les plus profondes des ''Seconds Analytiques'' à la théorie de la connaissance. Troisièmement, sa conception de l’explication scientifique comme connaissance des causes (''hoti'' contre ''dioti'') continue de nourrir la réflexion contemporaine en philosophie des sciences. Les analyses contemporaines de l’explication scientifique (Hempel, Salmon, Woodward) peuvent être mises en dialogue avec les distinctions aristotéliciennes. Quatrièmement, sa logique modale, longtemps négligée, a connu un renouveau avec le développement des logiques modales contemporaines (Kripke, Hintikka). La distinction entre modalité ''de dicto'' et ''de re'', déjà esquissée chez Aristote selon Patterson, est au cœur des débats actuels en métaphysique modale. Cinquièmement, sa réflexion sur les futurs contingents a inspiré toute une famille de logiques non classiques (Łukasiewicz, Prior, logiques temporelles) qui formalisent l’idée d’une suspension de la bivalence pour les énoncés sur l’avenir. ====Aristote dans la philosophie analytique contemporaine==== La philosophie analytique du XX{{e}} siècle, après une longue phase de scepticisme à l’égard de la logique aristotélicienne (Russell, dans ses premiers travaux, jugeait la syllogistique aristotélicienne sans intérêt logique réel), a redécouvert la richesse de l’''Organon''. Les travaux de Łukasiewicz, Patzig, Lear, Patterson, Crivelli, Malink, Bronstein, Harari ont rendu à la logique aristotélicienne sa profondeur conceptuelle. Plusieurs lignes de recherche méritent d’être signalées. La première concerne les rapports entre logique et métaphysique chez Aristote. Comme le montrent Patterson pour la logique modale et Harari pour la théorie de la démonstration, la logique aristotélicienne n’est pas une logique formelle abstraite, mais une logique ancrée dans une métaphysique des essences, des substances et des causes. Cette articulation, longtemps tenue pour une faiblesse, apparaît aujourd’hui comme une originalité féconde, en consonance avec les essentialismes contemporains (Kripke, Putnam, Fine). La deuxième concerne les rapports entre dialectique et science. Pellegrin, Brunschwig, Berti, Crubellier ont montré que la dialectique aristotélicienne n’est pas une simple discipline subordonnée à la science, mais un mode d’accès à la vérité qui complète la démonstration et qui prépare la saisie des principes. Cette réhabilitation de la dialectique a des résonances avec les approches contemporaines de l’épistémologie, qui reconnaissent le rôle des présuppositions, des controverses et de la discussion dans l’élaboration des connaissances. La troisième concerne les rapports entre langage, pensée et réalité. Le triangle sémiotique du ''De interpretatione'', la doctrine des catégories comme genres de l’être, l’analyse de la prédication, ont été réexaminés par la philosophie analytique du langage et par l’ontologie contemporaine. ====Bilan==== L’''Organon'' d’Aristote n’est pas un monument figé : c’est un ensemble vivant de questions, de distinctions et d’arguments qui continue de nourrir la pensée philosophique. Sa lecture exige une triple attention : à la ''lettre'' du texte, qui est souvent dense et elliptique ; à la ''tradition'' interprétative, qui en a sédimenté les significations ; et à la ''recherche contemporaine'', qui en renouvelle les enjeux. C’est par cette triple attention que l’''Organon'' peut être restitué dans sa fécondité, comme l’œuvre d’un penseur qui, plus de vingt-trois siècles après sa mort, continue de nous apprendre comment penser. == La philosophie de la nature == === Introduction générale : qu'est-ce que la « philosophie de la nature » chez Aristote ? === Avant d'entrer dans le détail des doctrines, il convient de poser ce que recouvre, chez Aristote, l'expression de « philosophie de la nature ». Le malentendu est en effet grand pour le lecteur moderne, habitué à entendre par « physique » une science mathématisée des phénomènes matériels, héritière de Galilée et de Newton. Or la ''phusikê'' aristotélicienne ne procède ni par mathématisation des phénomènes, ni par expérimentation provoquée. Elle se présente comme une enquête conceptuelle sur les êtres qui possèdent en eux-mêmes un principe interne de mouvement et de repos, ce qu'Aristote appelle les ''phusei onta'' ou « êtres par nature »<ref>Aristote, ''Physique'', II, 1, 192b13-23. Pour une introduction d'ensemble : Christopher Shields (éd.), ''The Oxford Handbook of Aristotle'', Oxford, Oxford University Press, 2012, partie III ; Andrea Falcon, ''Aristotle and the Science of Nature : Unity Without Uniformity'', Cambridge, Cambridge University Press, 2005.</ref>. Cette enquête forme un ensemble cohérent qui s'organise selon une hiérarchie de généralité. La ''Physique'' en huit livres pose les principes les plus généraux : la définition même de la nature, des causes, du mouvement, du temps, du lieu, de l'infini ; le livre VIII conduit, par voie de raisonnement, à un principe immobile du mouvement<ref>Aristote, ''Physique'', VIII, 1-10 ; sur l'unité du livre VIII et sa relation avec la ''Métaphysique'' Λ, voir Sarah Broadie, « Que fait le premier moteur d'Aristote ? », ''Revue philosophique de la France et de l'étranger'', 1993, et l'article « Aristotle's Natural Philosophy » de la ''Stanford Encyclopedia of Philosophy''.</ref>. Le ''De caelo'' ou ''Du ciel'' descend d'un degré et étudie le cosmos dans sa structure générale : la sphère céleste et son cinquième élément, la Terre au centre du monde, les quatre éléments sublunaires et leurs mouvements naturels. Le ''De generatione et corruptione'' poursuit en examinant les transformations mutuelles des éléments et la naissance des corps composés. Les ''Météorologiques'' étudient tous les phénomènes qui se produisent entre la Terre et la sphère lunaire : pluie, vents, comètes, tremblements de terre, mers et fleuves. Le ''De anima'' traite enfin de l'âme comme principe des êtres vivants, et les ''Parva naturalia'' développent les fonctions vitales particulières (sensation, mémoire, sommeil, longévité, respiration). La biologie d'Aristote (''Histoire des animaux'', ''Parties des animaux'', ''Génération des animaux'', ''Mouvement des animaux'') constitue le sommet appliqué de cet édifice, mais elle a fait l'objet d'un commentaire séparé qui n'est pas repris ici. Comme le souligne Pierre-Marie Morel dans son introduction au volume ''Aristote et la notion de nature'', la recherche contemporaine prête une attention renouvelée aux différents aspects de la philosophie aristotélicienne de la nature comme science et à sa place, centrale, dans l'ensemble du système<ref name="morel">Pierre-Marie Morel (éd.), ''Aristote et la notion de nature : Enjeux épistémologiques et pratiques'', Bordeaux, Presses Universitaires de Bordeaux, 1997, introduction.</ref>. La ''phusis'' n'est pas chez Aristote un domaine régional : elle constitue le paradigme à partir duquel se pense l'unité du réel, ce que confirme le fait que la métaphysique elle-même, dans les livres centraux, prenne pour modèle d'analyse la substance naturelle. Comme le rappelle encore Morel, reconnaître l'unité première du concept de ''phusis'' permet d'en apprécier la polysémie et la portée, en adoptant une pluralité d'approches<ref name="morel" />. L'intérêt philosophique de l'entreprise dépasse son intérêt historique. La philosophie de la nature aristotélicienne offre un modèle d'intelligibilité non réductionniste : elle prend les phénomènes au sérieux dans leur complexité propre, refuse de les ramener à un substrat homogène, et reconnaît à chaque niveau de réalité ses principes propres. Cette posture explique le retour contemporain à Aristote dans des domaines aussi divers que la philosophie de la biologie, la philosophie de l'esprit ou l'éthique des vertus<ref>Sur l'actualité de la philosophie naturelle d'Aristote, voir Mariska Leunissen, ''Explanation and Teleology in Aristotle's Science of Nature'', Cambridge, Cambridge University Press, 2010, ainsi que James G. Lennox, ''Aristotle's Philosophy of Biology'', Cambridge, Cambridge University Press, 2001.</ref>. === Première partie. La ''Physique'' : les principes du mouvement et du changement === ==== Chapitre I. La nature (''phusis'') comme principe interne de mouvement ==== ===== 1.1. Le texte fondateur : ''Physique'' II, 1 ===== C'est au début du livre II de la ''Physique'' qu'Aristote livre sa définition canonique de la nature : « la nature est principe et cause de mouvement et de repos pour la chose en laquelle elle se trouve immédiatement, par soi et non par accident »<ref>Aristote, ''Physique'', II, 1, 192b21-23. Pour une analyse détaillée du chapitre, voir William Charlton, ''Aristotle's Physics, Books I and II'', Oxford, Clarendon Press, 1992, p. 88-92, ainsi que les essais réunis par Lindsay Judson, ''Aristotle's Physics : A Collection of Essays'', Oxford, Clarendon Press, 1991.</ref>. Cette formule, d'apparence simple, contient en germe toute la philosophie de la nature aristotélicienne. Il faut en peser chaque mot. « Principe et cause » : la nature n'est pas une chose, mais un ''principe'', c'est-à-dire un point de départ, une source intelligible des phénomènes. Elle est « cause » au sens où Aristote distingue quatre types de causalité, sur lesquels nous reviendrons. « De mouvement et de repos » : la ''kinêsis'' aristotélicienne ne se restreint pas au déplacement local, mais englobe toutes les formes de changement : génération, corruption, croissance, altération, déplacement. Le repos n'en est pas le simple contraire ; il est l'état d'achèvement où la nature se trouve enfin pleinement actualisée, ou l'état précaire d'un être qui ne peut plus changer. « Pour la chose en laquelle elle se trouve immédiatement » : ce qui est par nature porte en soi son principe de changement. Une plante ne croît pas parce qu'on la pousse à croître, mais parce qu'elle possède en elle-même la capacité de croître. Les graines, à condition de trouver les éléments matériels nécessaires, deviennent par elles-mêmes des arbres. « Par soi et non par accident » : un médecin peut, certes, se soigner lui-même ; mais il le fait alors « par accident », au sens où ce n'est pas en tant que médecin (en tant que possédant son art à l'extérieur de soi) qu'il se soigne, mais en tant que matière passible de l'art médical. Au contraire, un être naturel se modifie de l'intérieur, en vertu de ce qu'il est essentiellement. ===== 1.2. L'opposition naturel / artificiel ===== L'exemple classique du lit éclaire cette définition. Si l'on plante en terre un lit en bois et qu'il en sorte quelque chose, ce sera du bois (un rejeton naturel issu de la matière du lit), non un autre lit<ref>Aristote, ''Physique'', II, 1, 193a12-17.</ref>. Le bois, en effet, possède en lui-même un principe de croissance ; le lit, en revanche, n'est qu'un agencement extérieur imposé par l'art du menuisier. Cet exemple, qu'Aristote reprend de manière insistante, ne vise pas à dévaloriser l'art (la ''technè'' a une dignité propre) mais à délimiter avec netteté le champ de la nature. Dans les ''phusei onta'', le principe du mouvement est intérieur ; dans les artefacts, il est extérieur. Cette distinction commande l'autonomie de la science physique : si tous les changements étaient l'effet de causes externes (un démiurge, des Idées séparées), il n'y aurait pas de physique au sens propre, mais une cosmologie théologique. La physique aristotélicienne ne nie pas l'existence d'un principe immobile (le livre VIII y conduira inéluctablement), mais elle commence par reconnaître la consistance propre du domaine naturel. ===== 1.3. L'homologie nature / art et la formule « l'art imite la nature » ===== Aristote n'oppose toutefois pas brutalement nature et art. Une homologie subtile les rapproche : tous deux produisent des choses, tous deux mettent en œuvre des fins, tous deux articulent matière et forme. C'est pourquoi la formule fameuse « l'art imite la nature » (''hê technê mimeitai tên phusin'') peut, sans paradoxe, faire de la ''phusis'' le modèle de toute ''technè''<ref>Aristote, ''Physique'', II, 8, 199a15-17. La formulation complète distingue deux fonctions de l'art par rapport à la nature : l'achèvement et l'imitation.</ref>. La formule complète d'Aristote est d'ailleurs plus nuancée : « l'art tantôt achève ce que la nature est incapable de mener à terme, tantôt l'imite ». L'art n'est donc pas pure imitation, mais collaboration et continuation de la nature. L'analyse fine de cette formule, comme le montre Alain Petit dans le volume Morel<ref>Alain Petit, « Forme et nature, ou comment l'art imite la nature », dans P.-M. Morel (éd.), ''Aristote et la notion de nature'', op. cit., p. 51-66.</ref>, soulève une difficulté centrale : Aristote prête-t-il à la nature une finalité « objective », sur le modèle d'une démiurgie cachée, ou bien la « ressemblance » de l'art et de la nature signifie-t-elle simplement que la nature, en tant qu'elle s'achemine régulièrement vers un état d'achèvement, présente une intelligibilité analogue à celle de l'art ? Les commentateurs récents ont insisté sur le caractère non-intentionnel de la téléologie aristotélicienne : la nature ne se ''propose'' pas une fin, comme un artisan se proposerait de fabriquer une statue ; elle s'achemine vers un état d'achèvement qui ''est'' sa fin. Cette précision importe pour le lecteur moderne : ne projetons pas sur Aristote une « finalité divine » externe, comme celle du Dieu artisan platonicien du ''Timée''. Chez Aristote, la finalité est immanente à chaque être ; elle s'identifie à sa forme et coïncide, dans l'achèvement, avec elle. Comme l'écrit Morel, « la nature ne tend pas seulement vers une fin, elle réalise une fin »<ref name="morel" />, mais elle la réalise sans représentation, sans visée intentionnelle, par sa seule structure interne. ===== 1.4. La nature : forme ou matière ? ===== ''Physique'' II, 1 pose une autre question : faut-il identifier la nature à la matière (comme le voulaient les premiers physiologues, Thalès, Anaximène, Héraclite) ou à la forme ? Aristote tranche en faveur de la forme, mais n'élimine pas la matière. Il y a deux raisons à cela. D'une part, la matière est ce qui sous-tend (''hupokeimenon'') tout changement : sans matière, pas de génération possible. La matière est principe au sens où elle est cette part d'indétermination grâce à laquelle un être peut devenir autre. D'autre part, et plus profondément, la nature comme principe est davantage la forme que la matière, car c'est la forme qui définit ce qu'une chose ''est'' et vers quoi elle s'achemine. La graine n'est pas pleinement la nature du chêne ; le chêne adulte, qui réalise pleinement sa forme spécifique, l'est davantage. Comme l'indique Aristote, « la nature entendue comme devenir est un passage à la nature en tant que telle » (''hê phusis hê legomenê hôs genesis hodos estin eis phusin'')<ref>Aristote, ''Physique'', II, 1, 193b12-13.</ref>. Le devenir est mouvement ''vers'' la nature pleinement réalisée. Cette tension entre nature comme forme et nature comme processus fait toute la richesse de la notion. Elle commande aussi l'organisation des recherches ultérieures. Si la nature était simple matière, il faudrait s'en tenir à un examen quantitatif. Si elle était pure forme, il faudrait s'en tenir à un examen logique des essences. Mais parce qu'elle est forme ''en train de se réaliser dans la matière'', la science physique requiert une articulation des deux points de vue. ===== 1.5. La nature, principe « passif » de mouvement ? ===== Bernard Besnier a soulevé, dans le volume Morel, une question subtile mais qui pèse lourd dans l'économie du système : si la nature est principe de mouvement, est-elle pour autant l'agent du mouvement ? Une lecture rapide laisserait croire que tout être naturel est ''automoteur'', c'est-à-dire qu'il se meut lui-même. Mais Aristote refuse cette conséquence : les êtres naturels ne se meuvent pas tous eux-mêmes, et notamment les éléments simples ne se meuvent pas eux-mêmes (un caillou ne décide pas de tomber). D'où la suggestion de Besnier : la nature serait principe ''passif'' de mouvement, c'est-à-dire principe par lequel un être est ''susceptible'' de subir tel ou tel mouvement conforme à sa nature, lorsqu'un agent extérieur le met en branle<ref>Bernard Besnier, « La nature comme principe passif de mouvement », dans P.-M. Morel (éd.), ''Aristote et la notion de nature'', op. cit., p. 27-49.</ref>. Le caillou, par sa nature, ''est apte à'' tomber ; quand on retire ce qui le retenait, il tombe. Cette lecture, qui sauve la cohérence avec le principe « tout ce qui est mû est mû par autre chose » (livre VII), explique aussi pourquoi la définition aristotélicienne du mouvement, au livre III, recourt aux concepts d'acte et de puissance plutôt qu'à celui de simple agent. ==== Chapitre II. Les principes du devenir : substrat, forme, privation ==== ===== 2.1. Le programme de ''Physique'' I ===== Avant de définir le mouvement, Aristote consacre le livre I de la ''Physique'' à élucider les principes du devenir. La question est ancienne : depuis Parménide, qui niait toute génération au nom du principe que rien ne saurait venir du non-être, la philosophie grecque butait sur l'aporie du changement. Aristote ne cherche pas à réfuter Parménide par l'expérience (ce serait, dit-il, comme tenter de prouver à un aveugle l'existence des couleurs) : Parménide se place hors du domaine de la physique. Il s'agit plutôt de montrer que la notion de changement est cohérente, dès lors qu'on distingue convenablement ses principes<ref>Aristote, ''Physique'', I, 2, 184b25-185a20. Pour une discussion approfondie, voir le volume collectif Katerina Ierodiakonou, Paul Kalligas et Vassilis Karasmanis (éd.), ''Aristotle's Physics Alpha : Symposium Aristotelicum'', Oxford, Oxford University Press, 2019.</ref>. L'analyse aristotélicienne, déployée aux chapitres 6 à 9 du livre I, identifie trois principes pour rendre compte de tout changement : le substrat (''hupokeimenon''), la forme (''morphê'' ou ''eidos'') et la privation (''sterêsis''). Lorsqu'un homme devient musicien, c'est l'homme qui est le substrat ; la musicalité est la forme acquise ; et la non-musicalité antérieure est la privation. Sans le substrat, on ne pourrait dire « cet homme est devenu musicien » : il n'y aurait que substitution, et non changement véritable. Sans la privation, la forme nouvelle ne serait pas « nouvelle ». Sans la forme, il n'y aurait rien de gagné. ===== 2.2. Comment échapper à Parménide ===== La force de cette analyse tient à la solution qu'elle apporte au paradoxe éléate. Parménide demandait : quand quelque chose vient à l'être, vient-il du non-être (auquel cas il vient de rien) ou de l'être (auquel cas il était déjà) ? Aristote répond par une distinction. Le devenir ne procède pas du non-être absolu, mais du non-être ''relatif'' à une forme déterminée, c'est-à-dire de la privation. Le musicien ne vient pas de rien : il vient de l'homme non-musicien. Et il ne vient pas non plus de quelque chose qui était déjà actuellement musicien : il vient d'un substrat qui était musicien ''en puissance''. Voilà introduit, en filigrane, le couple acte / puissance qui sera développé en détail au livre III et au livre IX de la ''Métaphysique''<ref>Aristote, ''Physique'', III, 1-3 ; ''Métaphysique'', Θ (IX). Voir l'édition Tricot (Paris, Vrin, plusieurs rééditions).</ref>. ===== 2.3. La matière connue par analogie ===== Mais qu'est-ce, plus précisément, que ce substrat ? Aristote distingue deux acceptions. Dans le cas où le changement n'est qu'accidentel (un homme devient musicien), le substrat est la chose elle-même (l'homme), qui demeure dans son essence. Dans le cas où le changement est substantiel (un être nouveau apparaît, comme dans la naissance), le substrat est plus difficile à saisir : c'est la matière elle-même, considérée non comme tel ou tel matériau particulier (le bronze, le bois) mais comme ce qui, n'étant pas encore tel ou tel être déterminé, est en puissance d'être déterminé. Cette « matière première » est connue, dit Aristote, ''par analogie''<ref>Aristote, ''Physique'', I, 7, 191a8-12.</ref> : ce que la matière est pour la forme dans le devenir d'une statue, c'est ce qu'elle est, plus généralement, dans tout devenir. Le concept échappe ainsi à la saisie directe, comme tout ce qui n'est pas pleinement actuel. Cette indétermination fait scandale aux modernes, qui aimeraient tenir entre leurs mains une matière première mesurable. Mais elle est la condition même de la possibilité du changement : si la matière était dès le départ pleinement déterminée, elle ne pourrait recevoir de nouvelles déterminations. ==== Chapitre III. Les quatre causes ==== ===== 3.1. La doctrine canonique : ''Physique'' II, 3 et 7 ===== Comprendre un être naturel, c'est en connaître les causes. Or il y a, selon Aristote, quatre acceptions du mot « cause ». Cette doctrine, exposée en ''Physique'' II, 3 et reprise en ''Métaphysique'' A, 3 et Δ, 2, structure toute la pensée aristotélicienne de la nature<ref>Aristote, ''Physique'', II, 3, 194b16-195a3 ; ''Métaphysique'', A, 3, 983a24-b6. Voir R. J. Hankinson, ''Cause and Explanation in Ancient Greek Thought'', Oxford, Clarendon Press, 1998, chap. 4-5.</ref>. La cause matérielle est ce dont une chose est faite : le bronze pour la statue, le bois pour le lit, les briques et les pierres pour la maison. Elle répond à la question : « de quoi ? » La cause formelle est l'essence ou la définition de la chose, ce qui fait qu'elle est ce qu'elle est. La forme de la statue d'Hermès n'est pas le bronze, mais la configuration qui lui donne sa figure et sa signification. Elle répond à la question : « qu'est-ce que c'est ? » La cause efficiente est le « ce d'où vient le commencement du changement » : le sculpteur qui produit la statue, le père qui engendre l'enfant, le médecin qui guérit. Elle répond à la question : « par qui ? » ou « par quoi ? » La cause finale est « ce en vue de quoi » la chose existe ou s'opère : la santé pour la promenade, l'ornement du temple pour la statue, la maturité pour la croissance. Elle répond à la question : « pour quoi ? » ===== 3.2. La coïncidence des causes formelle, efficiente et finale ===== Ces quatre causes ne sont pas mutuellement exclusives. Au contraire, dans bien des cas, plusieurs causes coïncident. Aristote insiste sur ce point : dans la génération naturelle, la cause formelle, la cause efficiente et la cause finale se rejoignent dans la même forme spécifique<ref>Aristote, ''Physique'', II, 7, 198a24-27.</ref>. C'est l'homme adulte (forme actuelle) qui engendre l'homme à venir (cause efficiente), et c'est vers la forme d'homme adulte que tend l'embryon (cause finale). On pourrait dire, avec une formulation contemporaine, que la forme spécifique fonctionne comme une ''attractrice'' du processus génératif : elle est ce qui structure le développement à chaque étape. Seule la cause matérielle reste, en un sens, distincte : elle est ce dans quoi la forme s'inscrit, mais elle est dirigée par elle. Dans le vocabulaire aristotélicien, la matière est « en puissance » ce que la forme est « en acte ». ===== 3.3. La nécessité hypothétique ===== Cette subordination de la matière à la forme commande une thèse de portée durable : la nécessité dans la nature n'est pas pure nécessité matérielle, mais nécessité ''hypothétique'' (''ex hupotheseôs''). La formule signifie ceci : si une fin doit être atteinte, alors certaines conditions matérielles doivent être remplies. Une scie, pour scier, doit être en fer ou en un matériau dur ; non parce que le fer engendre la scie, mais parce que la fonction (scier) requiert un matériau capable de la remplir. De même, un œil, pour voir, doit avoir telle structure de l'humeur cristalline ; non parce que cette structure produit la vision, mais parce que la vision la requiert<ref>Aristote, ''Physique'', II, 9, 199b34-200b8 ; ''Parties des animaux'', I, 1, 639b21-640a10. Voir Allan Gotthelf, ''Teleology, First Principles, and Scientific Method in Aristotle's Biology'', Oxford, Oxford University Press, 2012, chap. 1, pour une analyse rigoureuse de la finalité aristotélicienne comme thèse explicative.</ref>. La nécessité hypothétique inverse ainsi la lecture matérialiste : ce n'est pas la matière qui détermine la forme, mais la forme qui détermine ce qu'il faut de matière. Cela ne signifie pas que la matière soit indifférente, ni que la nécessité matérielle ne joue aucun rôle. Aristote reconnaît qu'il y a, dans la nature, des effets qui résultent des seules propriétés de la matière (la couleur des yeux, par exemple, peut être indifférente à la fonction visuelle). Mais le schéma général est celui d'une finalité qui sélectionne et oriente la matière. ===== 3.4. La téléologie aristotélicienne et ses interprétations ===== La téléologie aristotélicienne a été l'objet de débats acharnés, depuis l'Antiquité jusqu'à nos jours. Trois grandes interprétations s'affrontent. L'interprétation forte, traditionnelle, prête à la nature une finalité « objective » qui structure réellement les processus, sans pour autant impliquer une intention consciente. C'est la position que défendent, parmi les contemporains, Allan Gotthelf et Mariska Leunissen<ref>Voir notamment Mariska Leunissen, ''Explanation and Teleology in Aristotle's Science of Nature'', op. cit., et son article dans Christof Rapp, Klaus Corcilius (dir.), ''Aristoteles-Handbuch'', Stuttgart, J. B. Metzler, 2021. Pour Gotthelf, voir l'ouvrage déjà cité.</ref>. Pour eux, la téléologie n'est pas une simple façon de parler, mais la meilleure explication des régularités observables dans la nature, en particulier dans le vivant. L'interprétation déflationniste, défendue par Wolfgang Wieland dans son ouvrage classique de 1962, voit dans la téléologie aristotélicienne un « facteur d'intelligibilité » plutôt qu'une thèse métaphysique forte : Aristote n'attribuerait pas à la nature une visée au sens propre, mais utiliserait le vocabulaire finaliste comme cadre d'explication<ref>Wolfgang Wieland, ''Die aristotelische Physik'', Göttingen, Vandenhoeck & Ruprecht, 1962.</ref>. Cette lecture, qui a longtemps dominé l'aristotélisme allemand, est aujourd'hui largement contestée par les études anglo-saxonnes, qui voient dans la téléologie aristotélicienne une thèse réaliste sur la structure des processus naturels. Une voie intermédiaire, défendue notamment par Alain Petit dans le volume Morel, refuse à la fois la psychologisation de la finalité (la nature ne « vise » pas) et la déflation totale (la finalité n'est pas qu'une métaphore). Comme l'écrit Morel : « la nature réalise des fins qu'elle ne se propose pas, et ainsi, si l'on peut dire, et sans ajouter au paradoxe, elle les réalise mieux »<ref name="morel" />. La finalité naturelle est ''immanente'' et ''non-intentionnelle'', mais elle est ''réelle''. Cette discussion n'a rien d'académique : elle commande la possibilité même d'une biologie qui ne se réduise pas à la mécanique sans pour autant verser dans le créationnisme. C'est précisément ce que cherchent les biologistes des systèmes contemporains lorsqu'ils parlent de « causalité descendante » ou de « contraintes formelles ». ==== Chapitre IV. Le mouvement (''kinêsis'') : définition et espèces ==== ===== 4.1. La définition canonique : ''Physique'' III, 1 ===== Au début du livre III, Aristote propose ce qui est sans doute la définition la plus discutée de toute la ''Physique'' : le mouvement est « l'entéléchie de ce qui est en puissance, en tant que tel » (''hê tou dunamei ontos entelecheia hêi toiouton'')<ref>Aristote, ''Physique'', III, 1, 201a10-11. Voir Edward Hussey, ''Aristotle's Physics, Books III and IV'', Oxford, Clarendon Press, 1983, p. 55-65.</ref>. Cette définition est notoirement obscure. Charlton, dans son commentaire de ''Physique'' I et II, juge qu'elle est l'une des phrases les plus ardues d'Aristote<ref>William Charlton, ''Aristotle's Physics, Books I and II'', op. cit., préface ; comparer avec l'analyse de Hussey, op. cit., qui souligne le caractère paradoxal de la formule.</ref>. Elle articule trois concepts : l'entéléchie (''energeia'' / ''entelecheia'', terme aristotélicien désignant l'actualité), la puissance (''dunamis''), et la qualification « en tant que tel ». Il importe de souligner que l'entéléchie en question, dans la définition du mouvement, n'est pas une actualité achevée : Aristote vise précisément une actualité incomplète, paradoxalement en cours de réalisation. Pour comprendre cette formule, il faut suivre l'analyse que propose Bernard Besnier dans le volume Morel<ref>Bernard Besnier, dans P.-M. Morel (éd.), ''Aristote et la notion de nature'', op. cit.</ref>. Le mouvement, dit Aristote, est l'acte d'un être qui n'est pas encore pleinement actualisé. Prenons l'exemple de la construction d'une maison. Tant que la maison est encore à construire, elle est en puissance ; quand elle est construite, elle est en acte. Mais la ''construction'', l'activité même par laquelle la maison passe de la puissance à l'acte, n'est ni la pure puissance (sinon rien ne se passerait) ni l'acte achevé (sinon la maison serait déjà construite) : elle est l'acte de la maison-en-puissance ''en tant qu'elle est en puissance''. Voilà ce que vise la formule. La précision « en tant que tel » mérite l'attention. La pierre du chantier est aussi « en puissance d'être brisée » ; mais ce n'est pas en tant que telle qu'elle est en train d'être construite. Le mouvement est l'actualisation d'une puissance déterminée, considérée précisément dans sa puissance. Comme le souligne Besnier, cette définition « consiste à appliquer la relation acte/puissance […] au spectre des catégories ». Le mouvement n'est pas une réalité indépendante ; il est toujours mouvement ''de quelque chose'' selon une catégorie déterminée. Cela conduit aux quatre espèces de changement. ===== 4.2. Les quatre espèces de changement ===== Aristote distingue quatre types de changement, selon les catégories de la substance, de la qualité, de la quantité et du lieu<ref>Aristote, ''Physique'', V, 1, 225a34-225b9.</ref>. Le changement substantiel correspond à la génération et à la corruption : c'est la naissance ou la mort d'une substance. Une plante naît, un animal meurt. Aristote considère que c'est un cas-limite : à proprement parler, ce n'est pas un mouvement (''kinêsis'' au sens strict), car il n'y a pas de substrat qui demeurerait identique à lui-même tout au long du processus. C'est plutôt un ''metabolê'', un changement absolu. Le changement qualitatif ou altération (''alloiôsis'') est la transformation d'une qualité dans un substrat qui demeure : un fruit qui mûrit change de couleur et de saveur sans changer d'essence ; un homme apprend la grammaire et devient grammairien. Le changement quantitatif est l'accroissement (''auxêsis'') ou la diminution (''phthisis'') : la croissance d'un enfant, l'amaigrissement d'un malade. Le changement local ou translation (''phora'') est le déplacement d'un être d'un lieu à un autre. ===== 4.3. Le primat du mouvement local ===== Parmi ces quatre espèces, Aristote établit que le mouvement local est ''premier''<ref>Aristote, ''Physique'', VIII, 7, 260a26-260b7.</ref>. Cette priorité a plusieurs sens. Logiquement, le mouvement local peut exister sans les autres : un caillou peut être déplacé sans changer ni de forme, ni de qualité, ni de quantité. À l'inverse, les autres mouvements présupposent des transports locaux : la croissance suppose l'absorption d'aliments, qui sont déplacés. Ontologiquement, c'est le mouvement le plus pur, parce qu'il n'altère pas ce qu'il meut. Cosmologiquement enfin, c'est le mouvement local circulaire éternel des sphères célestes qui sera, au livre VIII, l'effet immédiat du moteur immobile. Cette hiérarchie n'est pas indifférente. Elle prépare le passage de la ''Physique'' au ''De caelo'' : si le mouvement local est premier, alors la science du ciel, qui étudie le mouvement local par excellence (le mouvement circulaire éternel des astres), occupe une place stratégique dans la philosophie de la nature. ==== Chapitre V. Le lieu (''topos'') et le vide (''kenon'') ==== ===== 5.1. La théorie du lieu : ''Physique'' IV, 1-5 ===== Le mouvement local suppose un lieu. Mais qu'est-ce qu'un lieu ? La question n'a rien d'évident. Le lieu n'est ni la matière qui le remplit (puisque la matière peut changer de lieu) ni la forme de ce qui s'y trouve (puisque le lieu lui survit quand la chose s'en va). Il faut donc, pour le définir, dégager une dimension propre. Aristote y procède en quatre étapes<ref>Aristote, ''Physique'', IV, 1-5. Pour une analyse détaillée, voir Hussey, ''Aristotle's Physics, Books III and IV'', op. cit., p. 99-121, ainsi que Benjamin Morison, ''On Location : Aristotle's Concept of Place'', Oxford, Clarendon Press, 2002.</ref>. D'abord, il établit que le lieu existe : la simple observation du transport (l'eau qui s'évapore et l'air qui prend sa place) montre qu'il y a quelque chose qui demeure, indépendant des corps qui s'y succèdent. Ensuite, il discute les conceptions reçues : le lieu n'est ni la matière ni la forme. Puis il pose sa propre définition : le lieu est « la limite immobile première du contenant » (''to peras tou periechontos akinêton prôton'')<ref>Aristote, ''Physique'', IV, 4, 212a20-21.</ref>. Enfin, il en déduit les propriétés : le lieu n'est pas un point, ni un corps, ni un intervalle vide entre les corps. L'idée fondamentale est que le lieu d'un corps est défini par les surfaces du corps qui l'enveloppe immédiatement. Mon lieu, en ce moment, est défini par la surface intérieure de l'air qui m'entoure. Quand je me déplace, je quitte un lieu (cette surface) pour un autre. Cette conception, étrange à nos yeux, présente trois traits caractéristiques. Elle est ''relationnelle'' : le lieu n'existe pas indépendamment des corps qui s'enveloppent les uns les autres ; il n'y a pas, comme chez Newton, un espace absolu préexistant. Elle est ''finitiste'' : le cosmos a une limite extérieure (la sphère des fixes), au-delà de laquelle il n'y a rien, pas même de l'espace vide. Et elle est ''anisotrope'' : il y a, dans le cosmos, des lieux privilégiés (le centre, la périphérie), qui ne sont pas équivalents et qui orientent les mouvements naturels. ===== 5.2. Le refus du vide ===== La question du vide reçoit, en ''Physique'' IV, 6-9, une réponse négative tranchée. Contre les Atomistes (Leucippe, Démocrite), qui faisaient du vide la condition même du mouvement, Aristote soutient que le vide est impossible<ref>Aristote, ''Physique'', IV, 6-9, 213a12-217b28.</ref>. Ses arguments sont multiples. Le plus célèbre porte sur la vitesse. Aristote soutient (à tort, comme l'établira Galilée) que la vitesse de chute d'un grave est inversement proportionnelle à la résistance du milieu. Or si le milieu était nul (dans le vide), la vitesse serait infinie, ce qui est absurde. Donc le vide n'existe pas. Cet argument, fragile à nos yeux, repose sur la conception aristotélicienne du mouvement comme requérant un milieu pour s'effectuer. D'autres arguments viennent de la cohérence de la doctrine du lieu. Si le lieu est défini par la surface du contenant, alors un vide serait un lieu sans contenu, ce qui est contradictoire (le lieu existe par et pour son contenu). Si le mouvement requiert une orientation (haut, bas, droite, gauche), alors un vide pur, indifférencié, ne pourrait orienter aucun mouvement. Le refus du vide commande toute la dynamique aristotélicienne, et il sera l'un des points les plus contestés à la Renaissance, puis dans la première moitié du XVII{{e}} siècle. Torricelli, en 1643, démontre par son expérience du tube de mercure que le « vide barométrique » est physiquement réalisable ; Pascal, par les expériences sur le Puy-de-Dôme en 1648, en confirme l'existence et établit la pression atmosphérique. La doctrine aristotélicienne, défendue jusque-là par les scolastiques sous la forme de la « horreur du vide », s'effondre alors progressivement. Mais il faut situer la position d'Aristote dans son cadre : pour lui, la nature est un ''plein'' continu, où chaque mouvement est transmis de proche en proche par contact. ==== Chapitre VI. Le temps (''chronos'') ==== ===== 6.1. Le temps, « nombre du mouvement » ===== La question du temps occupe les chapitres 10 à 14 du livre IV de la ''Physique''. Comme le souligne Chelsea C. Harry dans son ouvrage ''Chronos in Aristotle's Physics'', ces chapitres ont retrouvé une attention soutenue dans les études contemporaines, notamment depuis les commentaires de Couloubaritsis, Coope et Roark<ref>Chelsea C. Harry, ''Chronos in Aristotle's Physics : On the Nature of Time'', Springer, 2015. Voir aussi Ursula Coope, ''Time for Aristotle : Physics IV.10-14'', Oxford, Clarendon Press, 2005, et Tony Roark, ''Aristotle on Time : A Study of the Physics'', Cambridge, Cambridge University Press, 2011.</ref>. Aristote commence par poser une aporie : le temps existe-t-il, et comment ? Le passé n'est plus, le futur n'est pas encore, le présent est un instant indivisible : où donc le temps trouve-t-il son être ? La solution aristotélicienne consiste à lier intrinsèquement le temps au mouvement, sans pour autant les identifier. Le temps n'est pas le mouvement (un mouvement est dans un mobile particulier, alors que le temps est partout présent), mais il est « quelque chose du mouvement » (''ti tês kinêseôs''). Plus précisément, le temps est « le nombre du mouvement selon l'antérieur et le postérieur » (''arithmos kinêseôs kata to proteron kai husteron'')<ref>Aristote, ''Physique'', IV, 11, 219b1-2.</ref>. « Nombre » signifie ici : ce par quoi nous comptons et mesurons. Quand nous discernons des phases successives dans un mouvement et que nous les mettons en série, nous comptons le temps. Le « maintenant » (''to nun'') joue, dans le temps, le rôle que joue le mobile dans le mouvement : il est ce qui assure la continuité tout en marquant les divisions. ===== 6.2. Le temps et l'âme ===== Aristote pose ensuite une question délicate : si le temps est nombre, et que le nombre suppose quelqu'un qui compte, le temps existerait-il sans l'âme<ref>Aristote, ''Physique'', IV, 14, 223a16-29. Sur ce passage, voir Harry, ''Chronos in Aristotle's Physics'', op. cit., chap. 5, et Coope, ''Time for Aristotle'', op. cit.</ref> ? Sa réponse est nuancée : sans l'âme, il y aurait bien le mouvement, mais le temps comme nombre actuellement compté n'existerait pas. Il y aurait du « numérable », mais pas de « numéré ». Cette thèse n'implique pas un idéalisme du temps, comme on pourrait être tenté de le lire à la lumière de Kant. Pour Aristote, le mouvement est réel indépendamment de l'âme ; c'est seulement la ''mensuration'' du temps qui requiert un esprit qui compte. La distinction est subtile : il y a, dans le réel, des successions et des durées, indépendamment de toute conscience ; mais le temps mesuré, scandé en intervalles déterminés, requiert un sujet capable de comparer les phases et de les nombrer. C'est par cette ouverture que la psychologie pourra rejoindre la cosmologie : le « sens du temps » dont parlent les ''Parva naturalia'' (notamment le ''De memoria'') trouve ici son fondement physique. ===== 6.3. Le temps et le mouvement céleste ===== Aristote insiste enfin sur le lien entre le temps et le mouvement uniforme. Le temps est nombre du mouvement, mais c'est par référence au mouvement le plus régulier qu'il se mesure. Or le mouvement le plus régulier, le plus simple et le plus continu est le mouvement circulaire des sphères célestes. Le temps « universel » se mesure donc par référence au mouvement de la sphère des fixes, qui accomplit une révolution en un jour. Cette articulation conduit naturellement aux thèses du livre VIII et au ''De caelo''. ==== Chapitre VII. L'éternité du mouvement et le moteur immobile ==== ===== 7.1. Le mouvement est-il éternel ? ''Physique'' VIII, 1 ===== Le livre VIII de la ''Physique'' aborde la question la plus haute : le mouvement a-t-il commencé, ou est-il éternel ? Aristote argumente en faveur de son éternité par un raisonnement de réduction à l'absurde<ref>Aristote, ''Physique'', VIII, 1, 251a8-252b6. Voir l'analyse de Sarah Broadie, ''Nature, Change, and Agency in Aristotle's Physics'', Oxford, Clarendon Press, 1982.</ref>. Supposons que le mouvement ait eu un commencement. Avant ce commencement, il n'y avait pas de mouvement. Mais alors, pour que le mouvement commence, il fallait qu'un agent agisse sur un patient. Or cet agent, pour agir, devait passer de l'inactivité à l'activité, ce qui suppose un mouvement antérieur. Régression à l'infini, qui contredit l'hypothèse. Donc le mouvement n'a jamais commencé. Le même raisonnement vaut pour la fin du mouvement : si le mouvement devait cesser, il faudrait un agent qui le fasse cesser, donc un nouveau mouvement, etc. Le mouvement est ainsi éternel dans les deux sens. Cette thèse heurte de front le créationnisme judéo-chrétien. Au Moyen Âge, elle posera l'un des problèmes les plus aigus aux théologiens. Thomas d'Aquin tentera de la concilier avec la création ''ex nihilo'' en distinguant le commencement ''philosophique'' (que la raison ne peut établir avec certitude) du commencement ''révélé'' (que la foi atteste). Bonaventure, plus radical, cherchera à démontrer rationnellement, contre Aristote, que le monde a nécessairement eu un commencement temporel : il avance plusieurs arguments, dont le plus célèbre soutient qu'un nombre infini de jours révolus est impossible, puisqu'on ne saurait ajouter à l'infini ni le parcourir effectivement. La condamnation parisienne de 1277, par l'évêque Étienne Tempier, frappera entre autres les thèses aristotéliciennes sur l'éternité du monde, montrant à quel point cette doctrine fut perçue comme dangereuse. ===== 7.2. Le principe : « tout ce qui est mû est mû par autre chose » ===== Aux livres VII et VIII, Aristote pose le principe fondateur de sa dynamique : « tout ce qui est mû est mû par autre chose » (''pan to kinoumenon hupo tinos kineitai'')<ref>Aristote, ''Physique'', VII, 1, 241b24-242a15 ; VIII, 4-5.</ref>. Ce principe soutient toute la mécanique aristotélicienne : il n'y a pas d'auto-mouvement absolu au sens strict ; tout mouvement requiert un principe moteur distinct de ce qui est mû en tant que mû. Cette thèse semble contredite par les êtres vivants, qui paraissent se mouvoir d'eux-mêmes. Aristote distingue alors, à l'intérieur du vivant, ce qui est moteur et ce qui est mû : l'âme, comme forme du corps vivant, joue le rôle de principe moteur ; le corps, en tant que matière, est ce qui est mû. Cette distinction n'introduit pas un moteur extérieur au vivant (l'âme n'est pas séparée du corps), mais un principe moteur interne, distinct toutefois du corps en tant que mû. Au sein de l'âme elle-même, il faut encore distinguer les facultés qui agissent et celles qui sont actualisées. Le principe « tout ce qui est mû est mû par autre chose » est ainsi sauvé. Comme le note l'introduction de Moraux à l'édition Belles Lettres du ''Du ciel'', le rapport entre le corps premier (l'éther) et le moteur immobile reste cependant problématique chez Aristote<ref>Paul Moraux, introduction à Aristote, ''Du ciel'', Paris, Les Belles Lettres, 1965, p. xxx-lxxxv.</ref>. Si l'éther se meut naturellement en cercle, pourquoi aurait-il besoin d'un moteur supplémentaire ? Mais si tout mobile requiert un moteur distinct, alors l'éther ne peut s'auto-mouvoir, et il faut un moteur extérieur. La tension entre ces deux thèses traverse toute la cosmologie d'Aristote, et a donné lieu à des « hypothèses génétiques » (Jaeger, Solmsen) qui tentent d'y voir l'effet d'une évolution doctrinale<ref>Werner Jaeger, ''Aristoteles : Grundlegung einer Geschichte seiner Entwicklung'', Berlin, Weidmann, 1923 ; Friedrich Solmsen, ''Aristotle's System of the Physical World'', Ithaca, Cornell University Press, 1960.</ref>. ===== 7.3. Le moteur immobile ===== Pour éviter la régression à l'infini, Aristote pose donc l'existence d'un moteur immobile (''prôton kinoun akinêton'') qui meut sans être mû. Ce moteur ne peut mouvoir par contact ou par poussée, comme le ferait un agent ordinaire ; sinon il serait lui-même affecté, donc mû. Il meut « comme l'objet du désir meut le désirant »<ref>Aristote, ''Physique'', VIII, 6, 259b20 sq. ; voir aussi ''Métaphysique'', Λ, 7, 1072a25-b3.</ref>, c'est-à-dire comme cause finale plutôt que comme cause efficiente immédiate. Cette doctrine, esquissée à la fin du livre VIII de la ''Physique'', sera développée en ''Métaphysique'' Λ, 6 et suivants. Le moteur immobile y est caractérisé comme acte pur, sans matière ni puissance, donc immuable, indivisible et éternel. Sa vie consiste tout entière en l'acte le plus haut qui se puisse concevoir : la pensée. Mais comme il ne peut penser que l'objet le plus parfait, et que l'objet le plus parfait est lui-même, le moteur immobile est « pensée de la pensée » (''noêsis noêseôs noêsis'')<ref>Aristote, ''Métaphysique'', Λ, 9, 1074b34. Voir l'édition Tricot ou la traduction commentée de Marie-Paule Duminil et Annick Jaulin, ''Aristote. Métaphysique : Livre Lambda'', GF Flammarion, 2008.</ref>. Cette formule fait du divin aristotélicien une activité réflexive et autosuffisante, infiniment éloignée du Dieu créateur biblique. Le moteur immobile n'a pas conscience du monde qu'il meut ; il ne le providencialise pas ; il se contente d'être ce vers quoi le monde tend par amour. C'est par leur désir d'imiter sa perfection que les sphères célestes accomplissent leur révolution éternelle. La question du nombre exact des moteurs immobiles fait elle-même problème. Le livre Λ, 8 de la ''Métaphysique'' évoque, à côté du Premier Moteur, des moteurs immobiles supplémentaires associés aux mouvements particuliers des sphères célestes. Aristote envisage ainsi 47 ou 55 moteurs distincts, selon qu'on suit le compte d'Eudoxe ou celui de Calippe. La compatibilité de cette pluralité avec la primauté radicale du Premier Moteur reste l'un des débats les plus discutés de l'aristotélisme contemporain<ref>Aristote, ''Métaphysique'', Λ, 8, 1073a13-1074b14. Sur la pluralité des moteurs, voir l'article « Aristotle's Natural Philosophy » de la ''Stanford Encyclopedia of Philosophy'', section sur la cosmologie ; ainsi que Lindsay Judson, « Heavenly Motion and the Unmoved Mover », dans Mary Louise Gill et James G. Lennox (éd.), ''Self-Motion : From Aristotle to Newton'', Princeton, Princeton University Press, 1994, p. 155-171.</ref>. La doctrine n'envisage donc pas un moteur strictement unique, mais une hiérarchie de principes immobiles, dominée par un premier d'entre eux. La doctrine pose aussi des problèmes sur la nature exacte de la causalité du moteur. Enrico Berti a soutenu, dans une longue série d'articles, que le Premier Moteur aristotélicien doit être compris comme une ''cause efficiente'', et non simplement comme une cause finale, contre l'interprétation traditionnelle<ref>Enrico Berti, ''Nuovi studi aristotelici. II : Fisica, antropologia e metafisica'', Brescia, Morcelliana, 2005, en particulier les chapitres sur le Premier Moteur. Voir aussi le débat entre Berti et Aryeh Kosman dans les actes du ''Symposium Aristotelicum'' consacré à la ''Métaphysique'' Λ.</ref>. Sa thèse repose sur une relecture de plusieurs passages où le Premier Moteur paraît agir réellement, et non simplement attirer. La question reste ouverte. Pour notre propos, retenons que le Premier Moteur est éternel, immatériel, immobile, et qu'il meut éternellement la sphère des fixes par l'amour qu'il suscite. Il n'est pas le « créateur » du monde au sens biblique : il ne tire rien du néant, et le monde existe éternellement. Il rend compte de la permanence du mouvement cosmique : c'est à lui qu'Aristote rapporte, en dernière instance, l'éternité de la révolution céleste, dont le rythme régule à son tour celui des autres sphères et la vie sublunaire tout entière. === Deuxième partie. Le ''De caelo'' : la structure du cosmos === ==== Chapitre I. Le cinquième élément (l'éther) et le mouvement circulaire ==== ===== 1.1. La déduction du corps premier ===== Le ''De caelo'' s'ouvre par l'une des constructions doctrinales les plus marquantes de la philosophie ancienne. Aristote y déduit, à partir de considérations sur les mouvements simples, l'existence d'un cinquième élément, l'éther (''aithêr''), distinct des quatre éléments empédocléens (terre, eau, air, feu). Le raisonnement, qu'on lit en ''De caelo'' I, 2, procède ainsi<ref>Aristote, ''Du ciel'', I, 2, 269a2-269b17. Édition de référence : trad. Paul Moraux, Paris, Les Belles Lettres, 1965 ; nouvelle édition par Michel Federspiel, ''Du ciel'', Paris, Les Belles Lettres, 2016.</ref>. Il existe deux types de mouvements simples : le rectiligne (centripète ou centrifuge) et le circulaire. À chaque type de mouvement simple doit correspondre un corps simple dont ce mouvement est naturel. Or les quatre éléments traditionnels ont des mouvements rectilignes (vers le centre pour la terre et l'eau, vers la périphérie pour l'air et le feu). Il manque donc un cinquième corps, dont le mouvement naturel soit circulaire. Ce corps existe : c'est l'éther, dont sont faites les sphères célestes et les astres. Comme le souligne Federspiel dans son introduction à l'édition Belles Lettres de 2016, ce raisonnement « déductif » repose sur des prémisses qui ne sont pas elles-mêmes démontrées : que tout mouvement simple correspond à un corps simple, et que le mouvement circulaire est simple<ref>Michel Federspiel, introduction à Aristote, ''Du ciel'', Paris, Les Belles Lettres, 2016.</ref>. La force du système tient à sa cohérence interne, non à son enracinement empirique. ===== 1.2. Les propriétés de l'éther ===== De la nature même du mouvement circulaire, Aristote déduit les propriétés du cinquième corps. Le mouvement circulaire n'a pas de contraire (alors que le rectiligne ascendant a pour contraire le rectiligne descendant) ; il peut donc être éternel, sans avoir à craindre la corruption qu'engendrerait son contraire. Le cinquième corps est ainsi inengendré et incorruptible. Il ne croît ni ne diminue, ne s'altère pas qualitativement. Il n'a ni pesanteur ni légèreté, ces qualités étant propres aux corps mus en ligne droite. Comme le note Federspiel, ce « bijou doctrinal du ''Traité du ciel'' » est l'un des philosophèmes d'Aristote dont l'influence fut la plus durable, comparable, en portée historique, à la théorie des Idées de Platon<ref name="federspiel-intro">Michel Federspiel, introduction à Aristote, ''Du ciel'', op. cit.</ref>. Le terme et certaines représentations d'un milieu subtil et incorruptible connaîtront en effet une longue postérité : repris par les commentateurs, intégré aux doctrines de l'âme dans le néoplatonisme (où il devient le « véhicule subtil » de l'âme), incorporé à l'angélologie chrétienne comme substance des puissances célestes, repris par l'alchimie de la Renaissance comme « quinte essence ». L'« éther luminifère » dont les physiciens du XIX{{e}} siècle supposeront l'existence pour expliquer la transmission des ondes lumineuses et électromagnétiques ne reprend pas la doctrine aristotélicienne du cinquième corps, dont il est doctrinalement très éloigné, mais hérite par certains traits l'idée d'un milieu pénétrant, sans pesanteur, et omniprésent. Il faudra l'expérience de Michelson-Morley en 1887 et la relativité d'Einstein pour le chasser définitivement de la physique. ===== 1.3. Confirmations « endoxiques » ===== Aristote ne se contente pas de la déduction. En ''De caelo'' I, 3, il confirme la théorie par trois arguments « endoxiques », c'est-à-dire fondés sur les opinions admises. D'abord, le ''consensus gentium'' : tous les peuples ont placé le divin dans le lieu le plus élevé, ce qui suggère que ce lieu est d'une nature supérieure. Ensuite, l'observation traditionnelle : « durant tout le passé, à en croire les souvenirs qui se sont transmis d'âge en âge, il n'est jamais apparu de changement, ni dans le dernier ciel dans son ensemble, ni dans aucune de ses parties propres »<ref>Aristote, ''Du ciel'', I, 3, 270b13-15.</ref>. Enfin, l'étymologie : les Anciens auraient nommé ce lieu « éther » (''aithêr'') parce qu'il « parcourt sans cesse » (''aei thein'') le temps sans bornes, étymologie reprise de Platon (''Cratyle'', 410b). Ces arguments montrent qu'Aristote, malgré la rigueur de sa méthode, accorde une place considérable aux ''endoxa'', les opinions reçues, dans la confirmation de ses thèses cosmologiques. Comme le remarque Federspiel, les premiers chapitres du livre I forment un ensemble où se construit, selon son expression, un véritable ''mythologoumène'' aristotélicien<ref name="federspiel-intro" />. La cosmologie aristotélicienne, à son sommet, mêle déduction conceptuelle et adhésion aux représentations traditionnelles. ==== Chapitre II. La structure du cosmos ==== ===== 2.1. La cosmologie géocentrique ===== Le cosmos aristotélicien est sphérique, fini, géocentrique et anisotrope. La Terre, sphérique elle aussi, est immobile au centre. Aristote argumente longuement en faveur de cette immobilité, contre les pythagoriciens qui plaçaient la Terre sur une orbite autour d'un « feu central »<ref>Aristote, ''Du ciel'', II, 13-14, 293a18-298a20.</ref>. Ses arguments mêlent observation (les graves tombent vers le centre du monde, ce qui suggère que la Terre, étant elle-même grave, occupe ce centre) et raisonnement (un corps situé sur une trajectoire ne pourrait être en repos absolu, ce qui contredit la stabilité observable de notre demeure). La sphéricité de la Terre, sur laquelle Aristote insiste également, est démontrée par plusieurs arguments restés classiques. D'abord, l'ombre que la Terre projette sur la Lune lors des éclipses lunaires est toujours circulaire, ce qui n'est possible que si l'astre projetant l'ombre est sphérique. Ensuite, à mesure que les voyageurs se déplacent vers le sud, ils découvrent des étoiles méridionales jusque-là invisibles, et les constellations changent de hauteur sur l'horizon : Aristote cite à cet égard les exemples de l'Égypte et de la région de Chypre, où l'on observe des étoiles non perceptibles plus au nord. Enfin, la chute des graves vers le centre, si elle est universelle, ne peut produire qu'un agglomérat sphérique. Aristote rapporte aussi le chiffre, attribué aux mathématiciens de son temps, d'une circonférence terrestre de 400 000 stades, soit environ 70 000 kilomètres : surestimation par rapport au chiffre réel (environ 40 000 km), mais témoignage d'un effort de mensuration sérieux qu'Ératosthène affinera au siècle suivant. Autour de la Terre se déploient les sphères concentriques. D'abord les quatre éléments sublunaires, en couches étagées : l'eau au-dessus de la terre, l'air au-dessus de l'eau, le feu au-dessus de l'air. Puis, à partir de la sphère de la Lune, le domaine de l'éther, divisé en sphères qui portent les astres : la sphère de la Lune, celle de Mercure, celle de Vénus, celle du Soleil, celles de Mars, Jupiter, Saturne, et enfin la sphère des fixes, qui ferme le monde. ===== 2.2. Le système des sphères ===== Aristote, suivant Eudoxe et Calippe, complique cette image simple en multipliant les sphères pour rendre compte des irrégularités apparentes du mouvement des planètes. Selon le compte standard, Eudoxe avait posé 27 sphères au total (3 pour le Soleil, 3 pour la Lune, 4 pour chacune des cinq planètes alors connues, plus 1 pour la sphère des fixes) ; Calippe avait porté ce nombre à 34, en ajoutant des sphères pour mieux ajuster certaines anomalies observées. Aristote, pour sa part, en pose un nombre supérieur<ref>Aristote, ''Métaphysique'', Λ, 8, 1073b17-1074a14.</ref> : il introduit en effet des sphères « déroulantes » destinées à neutraliser, sur les sphères inférieures, l'effet des sphères extérieures qui les enveloppent, ce qui porte le total, selon les calculs et les manuscrits, à 47 ou 55 sphères. Chaque planète n'est donc pas mue par une seule sphère, mais par plusieurs, dont les axes diffèrent et qui se composent pour produire le mouvement observé. Cette astronomie, dérivée d'Eudoxe, vise à « sauver les phénomènes », c'est-à-dire à rendre compte des trajectoires apparentes des astres errants (les planètes, les ''planêta astra'') à partir de mouvements circulaires uniformes. La règle est ferme : aucun corps céleste ne peut avoir un autre mouvement naturel que circulaire ; si l'on observe des trajectoires en boucle, c'est qu'elles résultent de la composition de plusieurs mouvements circulaires. Cette exigence dictera l'astronomie pendant deux millénaires : elle conduira Ptolémée à introduire ses « épicycles » et ses « équants », et Copernic lui-même, au XVI{{e}} siècle, refusera encore l'idée d'orbites non circulaires. Il faudra Kepler, en 1609, pour briser cette contrainte par sa première loi (les orbites sont elliptiques). ===== 2.3. Anisotropie du cosmos ===== Le cosmos aristotélicien n'est pas isotrope. Contre Platon (''Timée'', 62c), qui défendait un univers où le « haut » et le « bas » seraient relatifs, Aristote insiste sur l'existence de directions absolues. Le centre et la périphérie sont des lieux privilégiés, et les couples haut/bas, droite/gauche, devant/derrière structurent l'espace cosmique. Cette anisotropie n'est pas une simple curiosité culturelle : elle commande la dynamique aristotélicienne. C'est parce qu'il y a un centre absolu vers lequel tendent les graves, et une périphérie absolue vers laquelle tendent les corps légers, que les mouvements naturels rectilignes sont possibles. Sans centre ni périphérie, les éléments simples n'auraient pas de lieu propre, et leur mouvement naturel serait inintelligible. ==== Chapitre III. Les quatre éléments sublunaires et leurs mouvements naturels ==== ===== 3.1. Le couple lourd / léger ===== Dans le monde sublunaire, les quatre éléments d'Empédocle (terre, eau, air, feu) se distinguent par leurs mouvements naturels. La terre et l'eau sont ''lourdes'' : elles tendent vers le centre. L'air et le feu sont ''légers'' : ils tendent vers la périphérie du monde sublunaire (la sphère intérieure de la Lune). Aristote distingue deux acceptions de la pesanteur. Le lourd et le léger ''relatifs'' : l'eau est plus lourde que l'air, mais plus légère que la terre. Et le lourd et le léger ''absolus'' : la terre est purement lourde (elle se dirige toujours vers le centre), le feu est purement léger (il se dirige toujours vers la périphérie). Cette distinction, explicite en ''De caelo'' IV, 1, conditionne toute la dynamique des transformations élémentaires<ref>Aristote, ''Du ciel'', IV, 1-4, 308a13-312a21.</ref>. ===== 3.2. Une dynamique des qualités, non des quantités ===== Comme le souligne Pierre Pellegrin, Aristote attache une grande importance à l'évidence sensible dans son traitement des questions physiques<ref name="pellegrin">Pierre Pellegrin, ''Le Vocabulaire d'Aristote'', Paris, Ellipses, 2001 ; voir aussi son ''Dictionnaire Aristote'', Paris, Ellipses, 2007.</ref>. Aristote écrit lui-même : « le résultat final […] pour la science physique c'est l'évidence sensible qui toujours l'emporte »<ref>Aristote, ''Du ciel'', III, 7, 306a11.</ref>. Cette confiance dans l'expérience commune explique en partie pourquoi sa physique privilégie les qualités (chaud, froid, sec, humide ; lourd, léger) sur les quantités mathématisables. Comme le rappelle Federspiel à la suite de Carteron, Koyré et Clavelin, la physique aristotélicienne est essentiellement non mathématique, et l'on ne peut la mathématiser sans en fausser l'esprit<ref>Henri Carteron, ''La Notion de force dans le système d'Aristote'', Paris, Vrin, 1923 ; Alexandre Koyré, ''Études galiléennes'', Paris, Hermann, 1939 ; Maurice Clavelin, ''La Philosophie naturelle de Galilée'', Paris, Armand Colin, 1968.</ref>. La vitesse, par exemple, n'est pas chez Aristote une grandeur indépendante mesurable : elle est une qualité du mouvement, qui dépend du mobile. Il n'y a pas de cinématique aristotélicienne au sens moderne. Quand Aristote utilise des proportions (par exemple en ''Phys.'' VII, 5 ou en ''De caelo'' I, 6), c'est dans le cadre étroit de la théorie euclidienne des grandeurs homogènes, non d'une mécanique mathématisée. ===== 3.3. La cause des mouvements naturels ===== La question est : qu'est-ce qui fait que le caillou tombe et que la flamme monte ? Aristote refuse l'explication mécanique pure (un ''vis impressa'' qui pousserait les corps) ; pour lui, le mouvement naturel est l'expression d'une tendance interne du corps à rejoindre son lieu propre. Comme le souligne Moraux dans son introduction à ''Du ciel'', Aristote « croyait découvrir une propriété intrinsèque du corps. Même s'il n'y avait rien au centre de l'univers, les lourds s'y rendraient, dit-il, en vertu de leur nature propre »<ref>Paul Moraux, introduction à Aristote, ''Du ciel'', op. cit.</ref>. Mais cette tendance interne suppose un agent. Et l'agent, selon le livre VIII de la ''Physique'', est ce qui a provoqué le passage de la puissance à l'acte. Quand l'eau (lourde en acte, légère en puissance) se transforme en air (léger en acte) sous l'action de la chaleur, l'ascension de cet air vers son lieu naturel n'est que le dernier épisode d'une série de changements. La cause véritable du transport est l'agent qui a provoqué le passage à la nouvelle entéléchie. C'est ce que Moraux résume ainsi : « la cause véritable de ce transport réside donc dans l'agent qui a provoqué le passage à l'entéléchie ». ===== 3.4. Le rôle du milieu et la critique galiléenne ===== La dynamique aristotélicienne est fondée sur l'hypothèse d'une résistance proportionnelle du milieu. La vitesse de chute d'un grave est, selon Aristote, proportionnelle à son poids et inversement proportionnelle à la résistance du milieu. C'est cette dernière clause qui rend le vide impossible : dans le vide, la vitesse serait infinie. Cette dynamique sera contestée par Galilée. Le résultat le plus connu est que, dans le vide, tous les corps tombent à la même vitesse (loi de la chute libre). Mais comme le souligne Federspiel, il faut éviter une lecture simpliste de cette « révolution scientifique »<ref name="federspiel-intro" />. La transition entre la physique aristotélicienne et la mécanique moderne s'est opérée par étapes, à travers de nombreuses relectures internes au cadre péripatéticien (théorie de l'''impetus'' chez Jean Philopon puis Buridan, distinctions médiévales entre vitesse et accélération). On ne saurait dater d'un événement unique le passage à la modernité. === Troisième partie. Le ''De generatione et corruptione'' : génération, corruption et transformation === ==== Chapitre I. Les deux espèces de génération ==== ===== 1.1. La génération absolue et la génération relative ===== Le traité ''De la génération et de la corruption'' prolonge l'examen des transformations dans le domaine sublunaire<ref>Édition française de référence : Aristote, ''De la génération et de la corruption'', éd. et trad. Marwan Rashed, Paris, Les Belles Lettres, 2005.</ref>. Sa question centrale est : qu'est-ce qui se passe lorsqu'un être nouveau apparaît ? Aristote distingue deux types de génération<ref>Aristote, ''De la génération et de la corruption'', I, 3, 317a17-b18.</ref>. La génération absolue (''genesis haplôs'') est le passage du non-être à l'être d'une substance : un homme naît, un arbre meurt. La génération relative (''genesis ti'') est l'altération d'une substance qui demeure : un homme devient musicien, un fruit mûrit. La distinction, qui peut paraître scolastique, joue en réalité un rôle considérable. Elle permet de répondre à un double péril : l'éléatisme de Parménide, qui niait toute génération au nom du principe que le non-être ne peut produire l'être, et l'atomisme de Démocrite, qui réduisait toute génération à l'agrégation et la dissociation d'atomes éternels. Aristote, comme dans la ''Physique'', sauve la possibilité du devenir en distinguant ses degrés et ses modalités. ===== 1.2. Critique de l'atomisme ===== Le ''De caelo'' III, 4 et le ''De gen. corr.'' I, 8 contiennent les principales critiques aristotéliciennes de l'atomisme<ref>Aristote, ''Du ciel'', III, 4, 303a3-303b8 ; ''De la génération et de la corruption'', I, 8, 324b25-326b6.</ref>. Ces critiques sont nombreuses et techniques, mais on peut en dégager trois grandes lignes. D'abord, Aristote conteste la possibilité physique d'une « génération » qui serait simple agrégation : si les atomes sont éternels et immuables, il n'y a pas véritablement de naissance ni de mort, mais seulement des arrangements et des séparations. Ce qui apparaît comme génération est, dans cette perspective, illusion subjective. Ensuite, il conteste la cohérence interne du système. Si les atomes sont indivisibles, comment se fait-il que les corps puissent paraître plus ou moins denses ? Si la division s'arrête à un certain seuil, pourquoi à celui-ci plutôt qu'à un autre ? Enfin, il conteste la pertinence explicative de l'atomisme. Réduire toute qualité à des arrangements géométriques d'atomes, c'est manquer la spécificité des qualités sensibles : la chaleur, le froid, le doux, l'amer ne se laissent pas réduire à des configurations spatiales. ==== Chapitre II. Action et passion ==== ===== 2.1. L'unité d'action et de passion ===== Une question centrale du ''De gen. corr.'' est : comment l'agent agit-il sur le patient ? Aristote refuse une lecture qui les séparerait : l'action n'est pas dans l'agent et la passion dans le patient, comme deux processus distincts. Au contraire, action et passion sont ''un seul et même mouvement'', considéré sous deux aspects<ref>Aristote, ''De la génération et de la corruption'', I, 7, 323b18-324a19.</ref>. Cette thèse, qui paraît étrange, est commandée par la doctrine du mouvement de ''Physique'' III. Le mouvement, on l'a vu, est l'entéléchie du mobile en tant que mobile. Or cet acte est ''dans le patient'', non dans l'agent. C'est dans la chose chauffée, et non dans le feu, que se trouve l'acte du chauffage. L'agent n'est, par rapport à cet acte, que le porteur antérieur de la forme à transmettre. Comme l'écrit Besnier dans le volume Morel : « il n'a [le moteur] pour privilège que l'antériorité de l'actualité de cette forme de mobilité (il a cette mobilité en acte, avant que la puissance sur laquelle il agit dans le mobile ne soit, à son tour, actualisée) »<ref>Bernard Besnier, dans P.-M. Morel (éd.), ''Aristote et la notion de nature'', op. cit.</ref>. Cette doctrine, qu'on appelle parfois la « théorie de la cinésis dans le patient », explique pourquoi le maître peut, en enseignant, ne pas apprendre lui-même : c'est dans l'élève, et non dans le maître, que se trouve l'acte d'apprendre. ===== 2.2. Le contact comme condition de l'action ===== Pour qu'il y ait action, il faut qu'il y ait contact (''haphê'') entre l'agent et le patient. Cette thèse exclut toute action à distance dans l'ordre physique ordinaire ; il faut une transmission de proche en proche. Elle commande la cohérence du système physique aristotélicien : les sphères célestes meuvent les sphères inférieures par contact, l'éther environne les éléments sublunaires, et la chaîne de transmission se poursuit jusqu'aux mouvements observables. Aristote distingue cependant le contact réciproque (entre deux corps qui se touchent et s'affectent mutuellement) du contact unilatéral (où l'agent affecte le patient sans en être affecté). Cette distinction prépare la conception d'une action sans réciprocité, dont l'analogie sera mobilisée pour penser l'action du moteur immobile. Il faut toutefois être prudent : le moteur immobile, immatériel et incorporel, ne meut pas comme un corps qui en pousserait un autre. La référence au contact ne fournit ici qu'une analogie limitée, et la lecture traditionnelle privilégie l'interprétation du moteur immobile comme cause finale plutôt que comme agent au sens physique du terme. Le « contact sans réciprocité » est donc une notion locale, valide pour certaines actions sublunaires, plutôt qu'un modèle direct pour la causalité du moteur immobile. ==== Chapitre III. Le mélange (''mixis'') ==== ===== 3.1. Qu'est-ce qu'un véritable mélange ? ===== L'analyse du mélange compte parmi les moments les plus techniques et les plus féconds du traité<ref>Aristote, ''De la génération et de la corruption'', I, 10, 327a30-328b22. Voir Marwan Rashed, introduction à son édition, op. cit., pour une analyse fine.</ref>. Aristote distingue le véritable mélange (''mixis'') de la simple juxtaposition (''sunthesis''). Dans la juxtaposition, les composants conservent leur identité : un tas de blé et un tas d'orge, mêlés mécaniquement, restent un tas de grains de blé et de grains d'orge, identifiables séparément. Dans le mélange véritable, les composants forment un nouveau corps homogène, dont les qualités ne sont plus celles des composants pris à part. Mais comment une telle fusion est-elle possible ? Si les composants disparaissaient totalement, on aurait une corruption suivie d'une génération, et non un mélange. Si les composants subsistaient inchangés, on aurait une simple juxtaposition. Il faut donc une troisième voie : les composants subsistent ''en puissance'', leurs qualités s'unissant pour former une qualité intermédiaire. ===== 3.2. La doctrine de la « subsistance en puissance » ===== Cette doctrine est subtile : les composants d'un mélange ne sont ni totalement présents (en acte) ni totalement absents. Ils subsistent en puissance, au sens où l'on peut, par analyse, les retrouver. C'est ce qui distingue le mélange véritable de la simple corruption : un alliage, par exemple, peut être décomposé pour retrouver les métaux qui le composent ; un mélange de cuivre et d'étain donne du bronze, mais le bronze peut être analysé pour redonner cuivre et étain. Cette analyse a des implications considérables pour la chimie aristotélicienne. Tous les corps composés (les ''homéomères'' d'Aristote, c'est-à-dire la chair, l'os, le sang, la pierre, le métal) sont des mélanges des quatre éléments en proportions diverses. La diversité des corps naturels résulte de cette combinatoire élémentaire, mais d'une combinatoire qualitative (un certain équilibre du chaud, du froid, du sec, de l'humide) plutôt que quantitative au sens moderne. Cette doctrine permet en outre de penser l'unité substantielle des composés sans tomber dans le dilemme entre, d'un côté, l'atomisme (qui ramène toute unité à une simple agrégation extrinsèque), et, de l'autre, l'éléatisme (qui ne reconnaît aucune réalité aux mixtes). Le bronze n'est pas un cuivre-et-étain superposés, ni une nouvelle substance qui aurait absorbé le cuivre et l'étain ; il est une réalité une, dont les composants subsistent à titre de potentialités récupérables. Cette conception influencera profondément la chimie médiévale et la pensée alchimique, qui voient dans la transmutation des métaux la possibilité de réorganiser ces équilibres qualitatifs internes. === Quatrième partie. Les ''Météorologiques'' : les phénomènes du monde sublunaire === ==== Chapitre I. Le programme de la science naturelle ==== ===== 1.1. La place des ''Météorologiques'' dans le système ===== Les ''Météorologiques'' occupent une place stratégique dans la philosophie naturelle aristotélicienne<ref>Édition française : Aristote, ''Les Météorologiques'', éd. et trad. Pierre Louis, Paris, Les Belles Lettres, 1982 ; nouvelle édition partielle par Jocelyn Groisard. Voir aussi l'introduction du ''Aristoteles-Handbuch'' sur la place de ce traité dans le système.</ref>. Après la ''Physique'' (principes généraux du mouvement), le ''De caelo'' (cosmologie générale) et le ''De generatione et corruptione'' (transformations élémentaires), les ''Météorologiques'' étudient les phénomènes qui résultent de l'action du ciel sur le monde sublunaire<ref>Aristote, ''Les Météorologiques'', I, 1, 338a20-339a5.</ref>. Le terme « météorologique » a chez Aristote un sens beaucoup plus large que celui qu'il a en français contemporain. Il désigne tous les phénomènes qui se produisent entre la surface de la Terre et la sphère de la Lune. Il englobe donc les phénomènes atmosphériques (pluie, neige, grêle, vent, tonnerre, éclair, foudre), mais aussi des phénomènes que nous classerions ailleurs : les comètes, la Voie lactée, les tremblements de terre, les marées, le cours des fleuves, la formation des minéraux et des métaux dans la terre. Ce vaste champ s'organise selon un principe explicatif fondateur : tout ce qui se produit dans le monde sublunaire dépend, en dernier ressort, de l'action des corps célestes (principalement du Soleil) sur les éléments terrestres. ===== 1.2. La dépendance du sublunaire au supralunaire ===== Cette dépendance n'est pas une astrologie au sens vulgaire. Aristote n'attribue pas aux astres une influence sur les destinées individuelles. Il s'agit plutôt d'une doctrine cosmologique cohérente : le mouvement éternel du ciel, par sa régularité, communique au monde sublunaire la chaleur et l'organisation qui rendent possible la génération et la corruption. Comme le souligne le ''Aristoteles-Handbuch'', cette articulation entre les deux mondes (éternel et corruptible, supralunaire et sublunaire) est la marque distinctive de la philosophie naturelle aristotélicienne<ref>Christof Rapp, Klaus Corcilius (dir.), ''Aristoteles-Handbuch'', op. cit., section sur les ''Météorologiques''.</ref>. L'unité du système ne repose ni sur une homogénéité matérielle (l'éther est radicalement distinct des quatre éléments) ni sur une géométrie commune, mais sur une chaîne causale qui descend du ciel à la terre. ==== Chapitre II. Les deux exhalaisons ==== ===== 2.1. La doctrine des deux exhalaisons ===== Au cœur des ''Météorologiques'' se trouve la théorie des deux exhalaisons<ref>Aristote, ''Les Météorologiques'', I, 3-4, 340b14-342a33.</ref>. Sous l'effet de la chaleur solaire, la Terre émet deux types d'évaporations : une exhalaison humide (vapeur d'eau, issue principalement des mers et des fleuves) et une exhalaison sèche (émanation chaude et combustible, issue de la terre elle-même). Ces deux exhalaisons, en montant vers les régions supérieures de l'atmosphère, donnent naissance à la plupart des phénomènes météorologiques. L'exhalaison humide, refroidie par les régions élevées, retombe sous forme de pluie, de neige ou de grêle. L'exhalaison sèche, enflammée au contact de la sphère du feu (immédiatement sous la sphère de la Lune), produit les phénomènes ignés : étoiles filantes, comètes, Voie lactée. ===== 2.2. Limites et fécondité de la doctrine ===== Ces explications, bien souvent fausses au regard de la science moderne, témoignent d'une démarche véritablement naturaliste. Aristote ne fait pas appel aux dieux pour expliquer le tonnerre ou la comète, comme le faisaient encore les théogonies de son temps. Il propose des explications par causes naturelles, qui mettent en jeu des principes universels (la chaleur, l'humidité, la transformation des éléments). Comme l'observe Pellegrin, ces explications restent contraintes par les moyens techniques de leur époque<ref name="pellegrin" />. L'absence de microscope, de chimie quantitative, de météorologie instrumentale, limite radicalement la portée des théories. Mais la ''démarche'' (expliquer le naturel par le naturel) inaugure ce qui deviendra la science moderne. ===== 2.3. Le livre IV des ''Météorologiques'' : vers une chimie qualitative ===== Le livre IV des ''Météorologiques'' (dont l'authenticité aristotélicienne est aujourd'hui débattue) propose une véritable chimie qualitative, en s'appuyant sur les couples chaud/froid et sec/humide<ref>Sur la question de l'authenticité du livre IV, voir Hans Baltussen, « Philology or Philosophy ? Simplicius on the Use of Quotations », ''Apeiron'', 2003 ; ainsi que les introductions des éditions récentes du traité.</ref>. Toute substance se caractérise par une combinaison de ces qualités, et ses transformations s'expliquent par des modifications de cette combinaison. Cette approche, prolongée par les commentateurs antiques et médiévaux, fournira à l'alchimie son cadre conceptuel jusqu'au XVII{{e}} siècle. Elle ne sera renversée que par la chimie de Lavoisier, qui substituera aux quatre éléments une nomenclature des corps simples fondée sur l'analyse pondérale. === Cinquième partie. Le ''De anima'' : l'âme, forme du corps vivant === ==== Chapitre I. La définition de l'âme ==== ===== 1.1. Le statut du ''De anima'' ===== Le ''De anima'' occupe une place singulière dans le corpus aristotélicien<ref>Édition française : Aristote, ''De l'âme'', éd. A. Jannone, trad. E. Barbotin, Paris, Les Belles Lettres, 1966 ; voir aussi la traduction de Richard Bodéüs, ''De l'âme'', Paris, GF Flammarion, 1993.</ref>. Il appartient en partie à la philosophie naturelle (puisque l'âme est principe de mouvement du vivant, et que le vivant est un être naturel), mais il déborde sur ce qu'on appellerait aujourd'hui psychologie ou philosophie de l'esprit. Aristote l'annonce dès le début (I, 1) : la connaissance de l'âme est l'une des plus précieuses parmi celles qui contribuent à toute vérité, et particulièrement à la connaissance de la nature. Le traité s'ouvre par un long examen des opinions des prédécesseurs (livre I), avant de proposer la définition aristotélicienne de l'âme (livre II, 1) puis d'analyser les facultés (livres II, 2 à III, 8) et enfin l'intellect (III, 4-8). ===== 1.2. La définition canonique : ''De anima'' II, 1 ===== La définition aristotélicienne de l'âme est posée en ''De anima'' II, 1 : l'âme est « l'entéléchie première d'un corps naturel ayant la vie en puissance » (''hê psuchê estin entelecheia hê prôtê sômatos phusikou dunamei zôên echontos'')<ref>Aristote, ''De l'âme'', II, 1, 412a27-28. Sur cette définition et les difficultés de sa traduction, voir Christopher Shields, ''Aristotle : De Anima'', Oxford, Clarendon Press, 2016, p. 162-176.</ref>. Décortiquons cette formule, qui condense toute la doctrine. « Entéléchie » : l'âme est l'actualité (''entelecheia'') correspondante d'un corps qui possède la vie en puissance. Le terme désigne ici un état d'achèvement et de réalisation, à distinguer du simple processus. « Première » : Aristote distingue l'entéléchie première de l'entéléchie seconde. La science possédée est entéléchie première par rapport à l'apprentissage qui y conduit ; l'exercice actuel de la science est entéléchie seconde par rapport à la science possédée. De même, l'âme est entéléchie première : elle est la ''capacité actuelle'' de vivre, la ''disposition stable'' à la vie, et non l'exercice de telle ou telle fonction vitale particulière. Quand un homme dort, il a son âme en entéléchie première (il vit), mais ses fonctions sensorielles ne sont pas en entéléchie seconde (il ne sent pas actuellement). « D'un corps naturel » : l'âme est l'entéléchie d'un corps. Elle n'est pas séparable du corps comme une chose distincte (contre Platon), mais elle est aussi distincte du corps en ce qu'elle en est la forme (contre les matérialistes). Le corps n'est pas l'instrument extérieur de l'âme, ni l'âme une partie du corps : ils forment ensemble la substance vivante. « Ayant la vie en puissance » : le corps doit être déjà disposé à la vie. Un cadavre n'a plus la vie en puissance : son âme l'a quitté, et il n'est plus un corps naturel au sens propre, mais un agrégat en voie de décomposition. L'œil mort n'est plus un œil que par homonymie, dit Aristote : il en garde l'apparence, mais non l'essence. ===== 1.3. L'analogie de la hache et de l'œil ===== Pour rendre cette définition intuitive, Aristote propose deux analogies célèbres<ref>Aristote, ''De l'âme'', II, 1, 412b10-413a3.</ref>. Si la hache était un être naturel, son essence serait son aptitude à trancher : la « tranche » serait son âme, le bois et le fer, sa matière. Quand la hache n'est plus capable de trancher, elle n'est plus une hache que de nom. De même, si l'œil était un animal complet, sa vue en serait l'âme : l'œil est la matière de la vue, et quand celle-ci disparaît, l'œil n'est plus un œil sinon par homonymie, comme un œil de pierre ou d'image. Ces analogies illustrent l'hylémorphisme : l'âme est à la matière vivante ce que la fonction est à l'organe. Elle n'est pas une chose à part, mais elle n'est pas non plus la simple matière organisée : elle est le principe formel et fonctionnel qui fait que cette matière est une matière ''vivante''. ===== 1.4. L'hylémorphisme contemporain ===== La doctrine hylémorphique de l'âme connaît un regain d'intérêt dans la philosophie de l'esprit contemporaine. Elle est invoquée comme alternative à la fois au dualisme cartésien (qui sépare âme et corps) et au réductionnisme matérialiste (qui identifie l'âme à des processus cérébraux). Des philosophes comme Christopher Shields, Sophia Connell ou Jennifer Whiting voient dans l'hylémorphisme aristotélicien une voie féconde pour penser l'unité du vivant<ref>Christopher Shields, ''Aristotle : De Anima'', op. cit. ; Sophia M. Connell, ''Aristotle on Female Animals'', Cambridge, Cambridge University Press, 2016 ; Jennifer Whiting, « Living Bodies », dans Martha Nussbaum et Amélie Rorty (éd.), ''Essays on Aristotle's De Anima'', Oxford, Clarendon Press, 1992.</ref>. Dans les sciences cognitives, des courants comme la cognition incarnée (''embodied cognition'') ou l'énactivisme présentent certaines analogies avec l'hylémorphisme aristotélicien : l'esprit n'y est pas un programme abstrait susceptible de s'incarner dans n'importe quel support, mais une activité d'un corps spécifique, dont la structure et la fonction sont indissociables. Ces rapprochements doivent toutefois être pris avec prudence : les sciences cognitives contemporaines travaillent dans un cadre biologique, neurologique et expérimental très différent de celui d'Aristote, et la psychologie aristotélicienne reste, fondamentalement, une théorie de l'âme comme principe général du vivant, et non une psychologie du mental au sens moderne. ==== Chapitre II. Les trois âmes et leurs facultés ==== ===== 2.1. La hiérarchie des âmes ===== Aristote distingue trois niveaux de vie, et donc trois niveaux d'âme<ref>Aristote, ''De l'âme'', II, 2-3, 413a20-415a13.</ref>. Cette hiérarchie n'est pas une stratification en couches séparées, mais une ''inclusion'' : chaque niveau supérieur intègre les niveaux inférieurs. L'âme végétative ou nutritive (''threptikon'') est le niveau le plus simple. Elle est commune à tous les vivants : plantes, animaux, hommes. Elle assure les trois fonctions fondamentales de la vie : la nutrition (assimilation des aliments), la croissance (augmentation de la masse vivante) et la reproduction (génération d'un être semblable). C'est elle qui définit la vie au sens minimal : sans nutrition ni reproduction, il n'y a pas de vivant. L'âme sensitive (''aisthêtikon'') ajoute, chez les animaux, la sensation, le désir et le mouvement local. Elle suppose toujours l'âme nutritive (un animal doit manger pour vivre), mais l'enrichit de fonctions nouvelles. La sensation permet à l'animal de discriminer son environnement ; le désir lui fait poursuivre ce qui est bon et fuir ce qui est mauvais ; le mouvement local lui permet d'agir sur cet environnement. L'âme intellective (''dianoêtikon'' ou ''noetikon'') est propre à l'homme. Elle ajoute, aux fonctions précédentes, la pensée rationnelle : le concept, le jugement, le raisonnement. ===== 2.2. La géométrie des âmes ===== Pour penser cette inclusion, Aristote propose une analogie avec les figures géométriques<ref>Aristote, ''De l'âme'', II, 3, 414b28-32.</ref>. Le triangle est inclus dans le quadrilatère au sens où le quadrilatère, en se définissant, présuppose et utilise les propriétés du triangle ; mais le triangle ne se confond pas avec un cas particulier du quadrilatère. De même, l'âme sensitive présuppose l'âme nutritive (sans nutrition, pas de sensation), mais elle n'est pas un cas particulier de la nutrition. Cette analogie a une portée méthodologique : Aristote rappelle, à la fin du chapitre, que c'est de chaque âme séparément qu'il faut traiter. Il n'y a pas d'« âme en général » dont les âmes particulières seraient des espèces : il y a des modes de vie qui s'enchaînent en complexité croissante. ===== 2.3. La triple causalité de l'âme ===== Au chapitre 4 du livre II, Aristote précise que l'âme est ''triplement'' cause du vivant : elle en est la cause formelle (elle est la forme du corps), la cause efficiente (elle initie les mouvements vitaux) et la cause finale (le corps est en vue de l'âme)<ref>Aristote, ''De l'âme'', II, 4, 415b8-28.</ref>. Cette concentration des causes dans l'âme illustre, au plus haut degré, la coïncidence des causes formelle, efficiente et finale dans la nature, déjà indiquée en ''Physique'' II, 7. ==== Chapitre III. La sensation et l'intellection ==== ===== 3.1. La théorie de la sensation ===== Au livre II, chapitre 5 et suivants, Aristote propose sa théorie de la sensation. La sensation est définie comme « la réception de la forme sensible sans la matière »<ref>Aristote, ''De l'âme'', II, 12, 424a17-21.</ref>. L'analogie célèbre est celle de la cire qui reçoit l'empreinte du sceau : la cire prend la forme du sceau (le motif gravé) sans recevoir le métal dont il est fait (l'or, le bronze). Cette analogie a une portée propre : elle indique que la sensation n'est ni une simple modification physique (comme le matérialisme ancien le voulait), ni une création spirituelle pure (comme le platonisme l'envisageait), mais une réception qualitative qui suppose à la fois une affection corporelle et une saisie formelle. C'est la doctrine que les scolastiques résumeront par la formule ''sensus est susceptivus formarum sine materia''. Concrètement, lorsque je vois un objet rouge, mon œil reçoit la « rougeur » au sens où il est actualisé selon cette qualité, mais il ne devient pas physiquement rouge comme un tissu teinté : il accueille la forme sans la matière qui la portait. Ce double caractère (physique parce que l'organe est affecté, formel parce que ce qui est reçu est l'aspect intelligible et non la matière du sensible) explique en grande partie le caractère cognitif de la sensation, qui n'est pas une simple réaction mécanique mais déjà un acte de discrimination. ===== 3.2. Les cinq sens et le sens commun ===== Aristote distingue les cinq sens externes (vue, ouïe, odorat, goût, toucher), chacun ayant son objet propre (la couleur pour la vue, le son pour l'ouïe, etc.). Mais il y a aussi un ''sens commun'' (''koinê aisthêsis''), qui n'est pas un sixième sens mais une faculté que partagent les cinq sens : elle perçoit ce qui n'est pas réductible à un sens particulier (le mouvement, le repos, la figure, le nombre, la grandeur). C'est elle aussi qui nous permet de comparer les sensations de différents sens (ce que je vois et ce que je touche est-il la même chose ?), et qui nous donne conscience de sentir<ref>Aristote, ''De l'âme'', III, 1-2, 424b22-427a16. Pour une étude approfondie, voir Pavel Gregorić, ''Aristotle on the Common Sense'', Oxford, Oxford University Press, 2007.</ref>. Comme le note Morel à propos du ''De memoria'', le sens commun est, en dernière instance, logé dans le cœur<ref>Pierre-Marie Morel, ''Aristote. Petits traités d'histoire naturelle'', Paris, GF Flammarion, 2000, introduction.</ref>. Aristote, comme on le verra plus loin, défend une doctrine cardiocentrique, contre l'encéphalocentrisme déjà entrevu par Alcméon de Crotone et qui sera défendu par les médecins hippocratiques puis par Galien. ===== 3.3. L'imagination et la mémoire ===== Outre la sensation, l'âme dispose d'une faculté qu'Aristote appelle ''phantasia'', traduite traditionnellement par « imagination » mais dont le sens est plus large. La ''phantasia'' est la capacité de produire et de retenir des images (''phantasmata'') qui sont comme des « affections » résiduelles des sensations passées. Elle joue un rôle médiateur entre la sensation et la pensée : sans ''phantasia'', il n'y aurait ni mémoire (qui retient les images du passé) ni pensée (qui s'exerce sur des images abstraites). Aristote pose à ce propos une formule fameuse : « jamais l'âme ne pense sans ''phantasme'' »<ref>Aristote, ''De l'âme'', III, 7, 431a16-17. Voir Pierre-Marie Morel, ''De la mémoire et de la réminiscence'', Paris, GF Flammarion, 2000, et Caston Victor, « Why Aristotle needs Imagination », ''Phronesis'', 1996.</ref>. Cette thèse soulève une difficulté majeure : comment l'intellect, qui est immatériel, peut-il dépendre d'images sensibles, qui sont matérielles ? L'enquête sur ce point traverse l'aristotélisme arabe et latin, et nourrit encore les débats contemporains sur la phénoménologie de la pensée. ===== 3.4. L'intellect (''nous'') : le passage le plus controversé ===== Au livre III, chapitre 5, Aristote consacre un texte d'une vingtaine de lignes à l'intellect, qui est l'un des plus brefs et des plus controversés de toute son œuvre<ref>Aristote, ''De l'âme'', III, 5, 430a10-25. Pour une analyse historique des interprétations, voir F. Brentano, ''Die Psychologie des Aristoteles'', Mainz, 1867 ; H. Davidson, ''Alfarabi, Avicenna, and Averroes on Intellect'', Oxford, Oxford University Press, 1992.</ref>. Il y distingue deux moments de l'intellect : un intellect ''patient'' (''pathêtikos''), qui reçoit les formes intelligibles, et un intellect ''agent'' (''poiêtikos''), comparé à la lumière qui « fait passer à l'acte » les couleurs en puissance. Le texte aristotélicien lui-même est extrêmement elliptique : il ne contient pas explicitement le vocabulaire de l'« abstraction » des formes à partir des images sensibles. Cette formulation, qui dominera la scolastique latine, est principalement héritée des lectures avicennienne et thomiste, qui ont systématisé ce qu'Aristote suggérait à peine. L'intellect agent est-il une partie de l'âme humaine, ou bien une réalité séparée et divine ? Est-il immortel, ou périt-il avec le corps ? Sur ces questions, la tradition commentatoriale a divergé profondément. Alexandre d'Aphrodise (vers 200 ap. J.-C.) y voyait un intellect divin, séparé, identifié au Premier Moteur. Avicenne et Averroès, dans la tradition arabe, en faisaient un intellect séparé unique pour toute l'humanité. Thomas d'Aquin, contre Averroès, défendait l'individualité de l'intellect agent en chaque âme humaine. Cette discussion, qui a façonné toute la métaphysique de l'esprit médiévale, dépasse le strict cadre de la philosophie de la nature. Mais elle illustre comment, à la frontière de la psychologie naturelle et de la métaphysique, Aristote ouvre des questions qui vont structurer la pensée occidentale pour des siècles. === Sixième partie. Les ''Parva naturalia'' : les fonctions vitales === ==== Chapitre I. Organisation des « Petits traités d'histoire naturelle » ==== ===== 1.1. Un corpus complémentaire au ''De anima'' ===== Les ''Parva naturalia'' forment un ensemble de courts traités qui étudient en détail les fonctions vitales examinées plus généralement dans le ''De anima''<ref>Édition française : Aristote, ''Petits traités d'histoire naturelle'', éd. et trad. Pierre-Marie Morel, Paris, GF Flammarion, 2000 ; voir aussi l'édition de René Mugnier, Paris, Les Belles Lettres, 1953.</ref>. Là où le ''De anima'' posait la définition de l'âme et la hiérarchie de ses facultés, les ''Parva naturalia'' descendent au niveau des opérations particulières et de leurs organes. Aristote lui-même indique le programme au début du ''De sensu''<ref>Aristote, ''De la sensation'', 1, 436a1-17.</ref>. Le corpus comprend principalement les traités suivants : ''De sensu et sensibilibus'' (sur la sensation et les sensibles), ''De memoria et reminiscentia'' (sur la mémoire et la réminiscence), ''De somno et vigilia'' (sur le sommeil et la veille), ''De insomniis'' (sur les rêves), ''De divinatione per somnum'' (sur la divination dans les songes), ''De longitudine et brevitate vitae'' (sur la longue et la brève durée de la vie), ''De juventute et senectute'' (sur la jeunesse et la vieillesse), ''De respiratione'' (sur la respiration), ''De vita et morte'' (sur la vie et la mort). ===== 1.2. Sensibles propres, sensibles communs et sensibles par accident ===== Le ''De sensu'' précise et complète la doctrine de la sensation du ''De anima''. Aristote y distingue trois types de sensibles. Les sensibles propres sont propres à un sens particulier : la couleur pour la vue, le son pour l'ouïe, l'odeur pour l'odorat, la saveur pour le goût, la texture (chaud, froid, dur, mou) pour le toucher. Les sensibles communs sont perceptibles par plusieurs sens : le mouvement, le repos, la figure, la grandeur, le nombre. Les sensibles par accident ne sont sensibles qu'indirectement : je vois cet homme blanc, où la blancheur est sensible ''par soi'' (sensible propre) et l'homme est sensible ''par accident'' (à travers la blancheur que je vois). ===== 1.3. Le sommeil et le rêve ===== Le ''De somno'' propose une analyse remarquable du sommeil. Pourquoi dormons-nous ? La réponse aristotélicienne mêle physiologie et finalité<ref>Aristote, ''Du sommeil et de la veille'', 2-3, 455a4-458a32.</ref>. Physiologiquement, le sommeil résulte d'un cycle complexe : les évaporations issues de la digestion s'élèvent vers la région de la tête, où elles se refroidissent ; refroidies, elles redescendent ensuite vers la région du cœur, siège de la sensation. C'est cet afflux refroidi autour du cœur qui empêche, temporairement, l'exercice des facultés sensorielles. La doctrine du sommeil s'inscrit donc dans le cadre cardiocentrique : ce n'est pas la « lourdeur » céphalique en tant que telle qui produit le sommeil, mais la modification thermique qu'elle entraîne au niveau du principe sensitif, situé dans le cœur. Finalement, le sommeil sert au repos des facultés sensorielles, qui ne pourraient exercer leur fonction continûment sans s'épuiser. Le ''De insomniis'' analyse les rêves comme des persistances de mouvements sensoriels après la disparition des stimuli externes. Les images sensorielles, conservées par la ''phantasia'', sont activées pendant le sommeil sans être contrôlées par l'intellect ni recoupées par les sensations actuelles, d'où leur caractère parfois absurde ou fantasmagorique. Quant au ''De divinatione per somnum'', il aborde une question délicate dans la culture grecque : les rêves prophétisent-ils l'avenir ? Aristote répond avec une prudence sceptique. Certains rêves peuvent annoncer un événement futur, mais c'est par hasard ou par une cause naturelle, non par une révélation divine. La position est subtile : Aristote ne nie pas le phénomène, mais en refuse l'interprétation surnaturelle. ==== Chapitre II. Le cardiocentrisme et la chaleur innée ==== ===== 2.1. Le cœur, principe de la vie ===== Aristote défend, contre la tradition médicale qui plaçait le siège des fonctions intellectuelles dans le cerveau (les médecins hippocratiques), une doctrine cardiocentrique : c'est dans le cœur que résident le principe vital, le sens commun et l'origine du mouvement<ref>Aristote, ''Parties des animaux'', III, 4, 665b9-667b13. Édition française : Aristote, ''Les Parties des animaux'', éd. et trad. Pierre Louis, Paris, Les Belles Lettres, 1956.</ref>. Cette doctrine est argumentée dans les ''Parties des animaux'' (notamment III, 4) et reprise dans plusieurs traités des ''Parva naturalia''. Le cœur est le premier organe à se former dans l'embryon, et c'est par lui que partent les vaisseaux qui irriguent tout le corps. Il est le siège de la chaleur innée (''emphuton thermon''), source de toute vie. Le cerveau, froid et humide, n'a qu'une fonction subalterne : il sert à modérer la chaleur excessive du cœur, comme un système de refroidissement. Comme le détaille le ''Aristoteles-Handbuch'', le cœur est le siège et l'origine des fonctions fondamentales de l'âme : la nutrition et la croissance, la perception sensorielle et le mouvement<ref>Christof Rapp, Klaus Corcilius (dir.), ''Aristoteles-Handbuch'', op. cit., section sur les ''Parties des animaux''.</ref>. Dans le ''De motu animalium'', Aristote ajoute que c'est dans la région cardiaque qu'agit le ''pneuma symphyton'' (souffle congénital), instrument du mouvement volontaire<ref>Aristote, ''Mouvement des animaux'', éd. et trad. Pierre-Marie Morel, Paris, GF Flammarion, 2013.</ref>. ===== 2.2. Une erreur féconde ===== Du point de vue de l'anatomie moderne, cette doctrine est fausse : c'est le cerveau, et non le cœur, qui est le siège de la sensation et du mouvement volontaire. Galien, au II{{e}} siècle, le démontrera expérimentalement par ses fameuses vivisections, en montrant notamment que la section du nerf laryngé récurrent provoque la perte de la voix : c'est donc bien le cerveau, et non le cœur, qui contrôle les fonctions motrices et sensorielles. Mais comme le note Pellegrin, aucune observation n'a amené Aristote à renverser l'une quelconque des conceptions fantaisistes qu'il a sur le fonctionnement du corps vivant<ref name="pellegrin" />. Cette erreur n'est pas anecdotique : elle révèle les contraintes méthodologiques de la science aristotélicienne. Aristote pratique la dissection (notamment dans l'''Histoire des animaux''), mais l'anatomie comparée ne lui permet pas de discerner la fonction nerveuse du cerveau. Il interprète les phénomènes selon des cadres préconçus (la primauté de la chaleur, l'analogie entre les fonctions vitales et les processus de cuisson) qui orientent l'observation. L'erreur est cependant féconde. Le cardiocentrisme aristotélicien, repris par les commentateurs, sera contesté à partir du XVI{{e}} siècle par les anatomistes modernes : Vésale (1543) renouvelle l'anatomie par la dissection systématique des cadavres humains ; Harvey (1628) découvre la circulation du sang et donne au cœur son rôle de pompe musculaire, dégagé de toute fonction cognitive ; Willis (1664) jette les bases de la neurologie moderne avec son ''Cerebri anatome''. La fausse doctrine d'Aristote, en provoquant la critique, a contribué au progrès de la science. ===== 2.3. La chaleur innée et le pneuma ===== La notion de chaleur innée occupe une place centrale dans la physiologie aristotélicienne. C'est elle qui distingue le vivant du non-vivant, la respiration servant à la maintenir et à la modérer. Cette chaleur est associée à un ''pneuma'' (souffle), véhicule des opérations vitales et organe de communication entre l'âme et le corps. Le pneuma aristotélicien n'est pas l'air ordinaire : il est un « corps subtil » apparenté, par ses propriétés, à l'éther céleste. Aristote suggère, dans le ''De generatione animalium'', que la chaleur de la semence n'est pas identique à celle du feu, mais s'apparente à la nature des astres : « ce souffle chaud que charrie la semence n'est pas identique au feu, mais il présente une certaine analogie avec l'élément des astres, la chaleur solaire étant, elle aussi, principe de vie »<ref>Aristote, ''De la génération des animaux'', II, 3, 736b29-737a7. Édition française : éd. et trad. Pierre Louis, Paris, Les Belles Lettres, 1961.</ref>. Cette suggestion, que Moraux qualifie de « curieuse allusion à l'élément astral », a donné lieu à de longs développements dans la tradition (Théophraste, Cicéron, Cléanthe), où le pneuma devient le véhicule de l'âme et le pont entre cosmologie et psychologie. Comme le note Morel, l'analyse du pneuma engage la spécificité même de la ''phusis'' : « le sperme n'apporte donc pas simplement la chaleur mais une chaleur qui vient de l'âme »<ref name="morel" />. La biologie aristotélicienne se distingue ainsi à la fois du matérialisme (la matière seule n'engendre pas la vie) et du dualisme (l'âme n'est pas un esprit séparé venant animer un corps mort), en posant un ''intermédiaire'' qui est à la fois matériel et porteur de forme. === Conclusion : la philosophie de la nature aristotélicienne aujourd'hui === Le parcours qui vient d'être proposé permet de mesurer l'ampleur et la cohérence de la philosophie de la nature aristotélicienne. Quatre traits caractéristiques méritent d'être soulignés en conclusion. Premier trait : l'autonomie de la nature. Aristote refuse à la fois la réduction de la nature à un substrat homogène (matière des Présocratiques, atomes des Atomistes) et son absorption dans une cause extérieure (Idées platoniciennes, Démiurge du ''Timée''). La nature a en elle-même son principe ; elle constitue un domaine propre, irréductible aux mathématiques comme à la théologie. Cette autonomie fonde la possibilité même d'une science de la nature. Deuxième trait : l'articulation matière/forme. L'hylémorphisme aristotélicien fournit un cadre qui permet de penser l'unité du vivant et la continuité du devenir sans les réduire ni à la matière brute ni à un esprit pur. Ce cadre, tombé en disgrâce avec la révolution mécaniste, retrouve aujourd'hui une actualité dans la philosophie de la biologie et de l'esprit. Troisième trait : la téléologie immanente. La nature aristotélicienne est ''finalisée'', mais sans intentionnalité. Elle s'achemine vers des états d'achèvement qui sont à la fois ses formes propres et ses fins. Cette téléologie immanente, qui ne suppose ni dessein divin ni conscience naturelle, offre une voie médiane entre le mécanisme aveugle et le créationnisme. Quatrième trait : la hiérarchie des sciences. La philosophie de la nature s'organise en une hiérarchie cohérente, qui descend des principes les plus généraux (''Physique'') aux phénomènes les plus particuliers (biologie). Chaque niveau a ses principes propres, sans se réduire au niveau inférieur. Cette hiérarchie n'est pas une stratification, mais une articulation d'ordre dans l'unité d'un système. L'enquête contemporaine, telle qu'elle se déploie dans des travaux de référence comme ceux de Morel, Judson, Ierodiakonou-Kalligas-Karasmanis, Harry, ou dans l'édition de Federspiel, met en lumière des aspects nouveaux de cette construction. La philosophie de la nature d'Aristote conserve une portée philosophique réelle, où des questions importantes continuent à être posées et reformulées. Comme le rappelle Federspiel à propos du seul ''Traité du ciel'', l'œuvre aristotélicienne « a été une matrice où puisèrent pendant près de deux mille ans les philosophes, les physiciens et les théologiens, païens, chrétiens, juifs ou musulmans »<ref name="federspiel-intro" />. Cette fortune historique tient à la profondeur conceptuelle de l'analyse aristotélicienne, qui a articulé avec rigueur les grandes questions du devenir, de la matière, de la forme, de la causalité et de la finalité. Lire Aristote, ce n'est donc pas seulement étudier un système scientifique périmé ; c'est comprendre la formation de concepts qui ont durablement structuré la pensée occidentale de la nature. == La biologie et la connaissance du vivant == === L'''Histoire des animaux'' – L'enquête empirique === ==== Méthode et organisation de l'œuvre ==== L{{'}}''Histoire des animaux'' (''Peri ta zôia historiai''), en dix livres dont trois sont probablement apocryphes, constitue un travail de collection et de description des faits concernant les animaux<ref>Aristote, ''Histoire des animaux'', I, 6, 491a7-14. Pour une introduction à la biologie aristotélicienne et à son importance dans la recherche contemporaine, voir Sophia M. Connell (éd.), ''The Cambridge Companion to Aristotle's Biology'', Cambridge, Cambridge University Press, 2021.</ref>. Le terme ''historia'' signifie ici « enquête » ou « recherche », non pas histoire au sens temporel. Il s'agit d'un exposé systématique des données empiriques qui sert de fondement aux explications causales développées dans les autres traités biologiques. Aristote y décrit l'anatomie externe et interne, le mode de reproduction, le régime alimentaire, les habitats et les comportements de plusieurs centaines d'espèces animales. Cette œuvre témoigne d'observations personnelles, complétées par des témoignages de pêcheurs, chasseurs et éleveurs<ref>D'Arcy Wentworth Thompson, introduction à sa traduction de l'''Histoire des animaux'', Oxford, 1910. Pour une mise au point récente sur le rôle de l{{'}}''Historia animalium'' dans le projet biologique aristotélicien, voir A. Gotthelf, « Data-organization, Classification, and Kinds: the Place of the History of Animals in Aristotle's Biological Enterprise », dans ''Teleology, First Principles, and Scientific Method in Aristotle's Biology'', Oxford, Oxford University Press, 2012, p. 261-292 ; ainsi que James G. Lennox, « Between Data and Demonstration: The Analytics and the Historia Animalium », dans A. Bowen (éd.), ''Science and Philosophy in Classical Greece'', New York, Garland, 1991, p. 261-295.</ref>. La place de cet ouvrage dans le projet scientifique aristotélicien a été reconsidérée par la recherche contemporaine. La question du rapport entre les ''Seconds Analytiques'' (théorie de la science démonstrative) et la pratique effective des traités biologiques a fait l'objet de débats nourris, notamment autour des travaux de David Balme, Allan Gotthelf et James G. Lennox<ref>Voir David Balme, « Aristotle's Use of Division and Differentiae », dans A. Gotthelf et J.G. Lennox (éd.), ''Philosophical Issues in Aristotle's Biology'', Cambridge, Cambridge University Press, 1987 ; Mariska Leunissen, « History of Animals », dans Connell (éd.), ''The Cambridge Companion to Aristotle's Biology'', op. cit.</ref>. ==== Principes de classification ==== Aristote organise les animaux selon une classification qui prend en compte plusieurs critères : mode de reproduction (vivipares, ovipares, larvipares), habitat (terrestres, aquatiques, aériens), régime alimentaire, présence ou absence de sang<ref>Aristote, ''Histoire des animaux'', I, 6, 490b7-491a6.</ref>. La division fondamentale oppose les animaux sanguins (en gros, les vertébrés) et les animaux non-sanguins (les invertébrés). Contrairement à une idée parfois répandue, Aristote ne se contente pas d'une classification dichotomique rigide. Il reconnaît que les espèces forment un continuum, avec des formes intermédiaires qui rendent difficile le tracé de frontières nettes. Ainsi, les éponges occupent-elles une position intermédiaire entre les plantes et les animaux<ref>Aristote, ''Histoire des animaux'', I, 1, 487b6-10.</ref>. L'idée d'une ''scala naturae'' (échelle de la nature) influencera la pensée biologique jusqu'à Lamarck et Darwin, même s'il convient de ne pas projeter rétrospectivement sur Aristote des conceptions évolutionnistes qui lui sont étrangères<ref>Voir James G. Lennox, ''Aristotle's Philosophy of Biology'', op. cit. ; Sophia M. Connell (éd.), ''The Cambridge Companion to Aristotle's Biology'', op. cit.</ref>. === Les ''Parties des animaux'' – La causalité finale dans le vivant === ==== Méthode de la biologie : de la description à l'explication ==== Le traité ''Des parties des animaux'' se propose d'expliquer pourquoi les animaux possèdent telles parties avec telles configurations. Après l'''Historia'', qui répond à la question « qu'est-ce qui est ? », vient l'étude qui répond à « pourquoi est-ce ainsi ? »<ref>Aristote, ''Des parties des animaux'', I, 1, 639b7-640a9.</ref>. Le livre I constitue un véritable traité de méthodologie scientifique. Aristote y défend la légitimité de l'étude de la nature : « il faut étudier chaque être sans dégoût, car en chacun réside quelque chose de naturel et de beau »<ref>Aristote, ''Des parties des animaux'', I, 5, 645a15-17.</ref>. Cette injonction est aujourd'hui souvent citée pour souligner l'importance accordée par Aristote à l'observation des phénomènes naturels, même dans leurs manifestations les plus humbles. ==== La priorité de la cause finale ==== Dans l'étude des vivants, la cause finale joue, selon Aristote, un rôle primordial. C'est en vue d'une fonction déterminée que chaque organe est configuré d'une certaine manière. Les dents de devant sont tranchantes pour couper, les molaires larges et plates pour broyer, parce que telle est leur fonction dans la nutrition<ref>Aristote, ''Des parties des animaux'', III, 1, 661b23-662a8.</ref>. Aristote critique le mécanisme d'Empédocle et des atomistes, qui expliquaient les organes par des rencontres fortuites de particules matérielles. Selon lui, le hasard ne peut produire la régularité observée dans la nature, où chaque espèce reproduit invariablement la même organisation<ref>Aristote, ''Des parties des animaux'', I, 1, 639b11-640a9.</ref>. La matière joue un rôle, par ses propriétés et ses nécessités, mais elle est, dans cette perspective, au service de la forme et de la fin. La nature de cette téléologie aristotélicienne est aujourd'hui l'un des sujets les plus discutés dans la recherche en philosophie de la biologie. Plusieurs interprétations s'opposent : la téléologie aristotélicienne est-elle « anthropocentrique » (la nature serait organisée pour le bien de l'homme), « cosmique » (le cosmos tout entier serait orienté vers une fin) ou « locale » (chaque espèce ou chaque organe a sa fin propre, sans qu'il y ait de finalité globale du vivant) ? Comme le souligne Pellegrin, « la téléologie aristotélicienne a comme horizon fondamental les espèces animales elles-mêmes »<ref>Pierre Pellegrin, ''Dictionnaire Aristote'', op. cit., article « Vivant », p. 196.</ref>. Cette lecture « locale », défendue notamment par Allan Gotthelf, James G. Lennox et Mariska Leunissen, est aujourd'hui largement partagée<ref>Voir Allan Gotthelf, ''Teleology, First Principles, and Scientific Method in Aristotle's Biology'', op. cit. ; James G. Lennox, ''Aristotle's Philosophy of Biology'', op. cit. ; Mariska Leunissen, ''Explanation and Teleology in Aristotle's Science of Nature'', Cambridge, Cambridge University Press, 2010.</ref>. ==== L'unité fonctionnelle de l'organisme ==== Chaque animal forme une totalité organisée dont toutes les parties coopèrent en vue de la vie de l'ensemble. Les poumons existent en vue de la respiration, qui existe en vue du refroidissement du cœur, qui existe en vue de la vie de l'animal. Réciproquement, la possession de poumons impose certaines autres caractéristiques : un animal pourvu de poumons doit avoir un sang et un cœur, doit respirer, donc doit avoir des voies aériennes<ref>Aristote, ''Des parties des animaux'', III, 6, 668b33-669a13.</ref>. Cette conception « systémique » de l'organisme, où chaque partie trouve sa raison d'être dans sa contribution au tout, est aujourd'hui souvent rapprochée des approches fonctionnalistes en biologie contemporaine. Elle fonde aussi la possibilité d'une connaissance rationnelle du vivant : malgré la complexité des formes de vie, on peut les comprendre en dégageant les rapports fonctionnels entre les parties et le tout<ref>Voir Sophia M. Connell (éd.), ''The Cambridge Companion to Aristotle's Biology'', op. cit. ; Christopher Shields (éd.), ''The Oxford Handbook of Aristotle'', op. cit., partie III.</ref>. === La ''Génération des animaux'' – Reproduction et hérédité === ==== Les modes de reproduction ==== Le traité ''De la génération des animaux'' étudie la reproduction sexuée et asexuée, la formation de l'embryon, l'hérédité. Aristote distingue plusieurs modes de génération : par accouplement (la plupart des animaux), par génération spontanée (certains insectes, les anguilles qu'on croyait alors naître de la vase), par bourgeonnement (certaines plantes)<ref>Aristote, ''De la génération des animaux'', I, 1, 715a1-18.</ref>. Dans la génération sexuée, le mâle apporte, selon Aristote, la forme et le principe du mouvement (la semence), tandis que la femelle apporte la matière (les menstrues chez les vivipares, l'œuf chez les ovipares)<ref>Aristote, ''De la génération des animaux'', I, 20, 729a9-b21.</ref>. Cette théorie reflète des préjugés androcentrés de l'époque : elle dénie à la femelle tout rôle actif dans la génération, la réduisant à fournir le substrat matériel informé par la semence mâle. La recherche contemporaine, notamment féministe, a discuté de manière approfondie ce volet de la pensée aristotélicienne ; elle souligne à la fois sa cohérence interne avec la métaphysique du composé hylémorphique et le caractère contestable de ses présupposés empiriques et sociaux<ref>Voir Sophia M. Connell, ''Aristotle on Female Animals: A Study of the Generation of Animals'', Cambridge, Cambridge University Press, 2016 ; ainsi que les contributions à Connell (éd.), ''The Cambridge Companion to Aristotle's Biology'', op. cit.</ref>. ==== L'embryogenèse et la formation des parties ==== Aristote décrit avec précision le développement de l'embryon de poulet, qu'il a observé en ouvrant des œufs à différents stades d'incubation. Il voit d'abord se former le cœur, qui bat dès le troisième jour, puis progressivement les autres organes<ref>Aristote, ''Histoire des animaux'', VI, 3, 561a4-561b13.</ref>. Cette description fonde une conception épigénétique de l'embryologie : l'embryon se construit progressivement, par différenciation graduelle, et non par simple croissance d'un organisme préformé miniature. La cause de cette différenciation progressive est, selon Aristote, la chaleur vitale contenue dans la semence, qui « cuit » et façonne la matière menstruelle comme la présure fait cailler le lait<ref>Aristote, ''De la génération des animaux'', II, 4, 739b20-740a4.</ref>. Ce processus n'est pas mécanique mais finalisé : c'est en vue de telle forme déterminée (l'homme adulte, le poulet adulte) que les parties se forment dans tel ordre. ==== L'hérédité et la ressemblance ==== Pourquoi les enfants ressemblent-ils à leurs parents ? Aristote explique ces phénomènes par le degré de « cuisson » et de maîtrise qu'exerce la semence mâle sur la matière femelle. Si la cuisson est parfaite, l'enfant ressemble au père ; si elle l'est moins, à la mère ; encore moins, aux grands-parents<ref>Aristote, ''De la génération des animaux'', IV, 3, 767b15-768a14.</ref>. Ces explications, rudimentaires au regard de la génétique moderne, témoignent d'un effort pour rendre compte rationnellement de phénomènes que d'autres cultures expliquaient par l'intervention divine. Aristote pose les questions fondamentales de la biologie (qu'est-ce que la vie ? comment les formes se transmettent-elles ? pourquoi existe-t-il une telle diversité d'espèces ?) et invente des méthodes pour y répondre. === Le ''De motu animalium'' et le ''De incessu animalium'' – Le mouvement animal === ==== Le principe du mouvement volontaire ==== Le ''De motu animalium'' examine comment les animaux se meuvent localement par eux-mêmes. Aristote établit qu'un animal ne peut se mouvoir que s'il s'appuie sur quelque chose d'immobile : de même qu'on ne peut pousser un bateau depuis l'intérieur du bateau, l'animal doit prendre appui sur le sol, l'eau ou l'air<ref>Aristote, ''Du mouvement des animaux'', 2, 698b7-699a11.</ref>. Le principe moteur interne est le désir, excité par la sensation ou l'imagination d'un objet désirable. Le désir entraîne un réchauffement dans la région du cœur, qui se communique aux membres et produit leur contraction ou extension<ref>Aristote, ''Du mouvement des animaux'', 10, 703a4-b2.</ref>. Le mouvement animal, bien que produit de l'intérieur, a toujours une cause finale externe : l'objet désiré qui meut sans être mû. ==== Les modes de locomotion ==== Le ''De incessu animalium'' décrit et explique les différents modes de locomotion : marche, course, vol, nage. Aristote établit des lois générales : les animaux sanguins ne peuvent avoir plus de quatre points d'appui, car la nature ne fait rien en vain, et quatre suffisent à assurer la stabilité<ref>Aristote, ''De la marche des animaux'', 8, 708a9-708b9.</ref>. Les oiseaux ont des ailes parce qu'ils ont le corps léger et la poitrine large et musculeuse<ref>Aristote, ''De la marche des animaux'', 10, 710a10-710b7.</ref>. Ces études relèvent de ce que nous appellerions aujourd'hui biomécanique. Elles montrent qu'Aristote ne se contente pas de contempler les formes vivantes, mais cherche à en expliquer le fonctionnement par des causes physiques, sans abandonner le cadre téléologique général qui voit dans la nature l'action d'une finalité immanente. == La philosophie première : la métaphysique == === Objet et divisions de la métaphysique === ==== La science de l'être en tant qu'être ==== La métaphysique, qu'Aristote nomme « philosophie première » ou « sagesse », est définie comme la science de l'être en tant qu'être (''on hêi on'') et de ce qui lui appartient essentiellement<ref>Aristote, ''Métaphysique'', Γ, 1, 1003a21-22. Sur l'unité (et la possible disparité) du projet métaphysique aristotélicien, voir Annick Jaulin, ''Aristote. La métaphysique'', op. cit., Introduction et chap. « La science recherchée » ; Vasilis Politis, ''Routledge Philosophy GuideBook to Aristotle and the Metaphysics'', op. cit.</ref>. Cette formulation distingue la métaphysique des sciences particulières qui étudient un aspect de l'être : la géométrie l'étudie en tant que continu, la biologie en tant que vivant. Seule la métaphysique considérerait l'être dans sa totalité. Comme le souligne Annick Jaulin, le terme « métaphysique » lui-même n'apparaît jamais dans les traités aristotéliciens : il s'agit d'un titre éditorial donné par Andronicos<ref>Annick Jaulin, ''Aristote. La métaphysique'', op. cit., Introduction.</ref>. Pellegrin recense quant à lui plusieurs définitions concurrentes de cette science chez Aristote : recherche des premiers principes et des premières causes, science de l'être en tant qu'être, science de la substance, science théologique<ref>Pierre Pellegrin, ''Dictionnaire Aristote'', op. cit., article « Métaphysique », p. 136-137.</ref>. Cette science est dite « première » en deux sens : elle porte sur ce qui est premier dans l'ordre de l'être (les principes et causes premiers), et elle examine les principes communs à toutes les sciences, comme le principe de non-contradiction<ref>Aristote, ''Métaphysique'', Γ, 3, 1005a19-b8.</ref>. Elle est aussi qualifiée de « science théologique » car elle porte sur ce qu'il y a de plus divin, le Premier Moteur immobile<ref>Aristote, ''Métaphysique'', Α, 2, 983a5-10.</ref>. ==== La question de l'unité de la métaphysique ==== Comment la métaphysique peut-elle être à la fois ontologie générale, étude de l'être en tant qu'être, et théologie, étude d'un être particulier (Dieu) ? Cette tension a donné lieu à plusieurs interprétations dans l'histoire de la philosophie<ref>Pour un état de la question, voir Annick Jaulin, ''Aristote. La métaphysique'', op. cit. ; Vasilis Politis, ''Routledge Philosophy GuideBook to Aristotle and the Metaphysics'', op. cit., chap. 1-2.</ref>. Une lecture, défendue notamment par Werner Jaeger, voit dans le corpus de la ''Métaphysique'' deux projets juxtaposés correspondant à deux périodes différentes de la pensée d'Aristote : une métaphysique ontologique générale, plus tardive, et une métaphysique théologique d'inspiration platonicienne, plus ancienne<ref>Werner Jaeger, ''Aristote. Fondements pour une histoire de son évolution'', op. cit. Cette lecture est cependant aujourd'hui largement contestée ; voir Annick Jaulin, ''Aristote. La métaphysique'', op. cit.</ref>. Une autre lecture, défendue par Thomas d'Aquin et reprise par toute une tradition, soutient que l'unité est assurée par le fait que Dieu est principe de tout être<ref>Thomas d'Aquin, ''In Metaphysicam Aristotelis Commentaria'', Prooemium.</ref>. C'est ce que la tradition appellera l'« onto-théologie ». Une troisième lecture, plus récente, défendue notamment par Pierre Aubenque, considère que la synthèse entre ontologie et théologie est en fait impossible et que la ''Métaphysique'' aristotélicienne reste inachevée comme problème<ref>Pierre Aubenque, ''Le problème de l'être chez Aristote'', op. cit. Voir aussi Pierre Pellegrin, ''Dictionnaire Aristote'', op. cit., p. 137.</ref>. Une quatrième lecture, présente notamment dans le travail d'Annick Jaulin, refuse de lire l'expression « substance séparée » au sens transcendant et soutient qu'Aristote, plutôt que d'opérer une rupture avec la physique, « réinscrit la substance immobile dans la tradition des penseurs de la nature »<ref>Annick Jaulin, ''Aristote. La métaphysique'', op. cit., chap. « Les principes et les causes ».</ref>. Le débat reste ouvert. ==== Les apories de la métaphysique ==== Le livre Β de la ''Métaphysique'' expose méthodiquement les apories, c'est-à-dire les difficultés que rencontre toute tentative de construire une science de l'être et des principes premiers : Y a-t-il une science unique de toutes les causes ? La science de l'être porte-t-elle aussi sur les principes de la démonstration ? Y a-t-il seulement des substances sensibles, ou existe-t-il aussi des substances séparées ? Les principes sont-ils universels ou particuliers ? En acte ou en puissance ?<ref>Aristote, ''Métaphysique'', Β, 1, 995b4-996a17.</ref> Cette méthode aporétique est caractéristique de la démarche aristotélicienne : « Il faut, pour bien rechercher, avoir auparavant développé les apories »<ref>Aristote, ''Métaphysique'', Β, 1, 995a24-27.</ref>. === La substance (livres Z-H) === ==== Qu'est-ce que la substance ? ==== Si l'être se dit en plusieurs sens, et si la substance est le sens premier de l'être, la question centrale de la métaphysique devient : qu'est-ce que la substance (''ousia'') ?<ref>Aristote, ''Métaphysique'', Ζ, 1, 1028a10-1028b2.</ref> Quatre candidats se présentent : l'essence (''to ti ên einai''), l'universel (''to katholou''), le genre (''to genos''), et le substrat (''to hupokeimenon''). Ce dernier peut être entendu en trois sens : la matière, la forme, ou le composé des deux. Aristote élimine d'abord l'universel et le genre : ils ne peuvent être substances parce qu'ils se prédiquent de plusieurs choses, alors que la substance de chaque chose lui est propre<ref>Aristote, ''Métaphysique'', Ζ, 13, 1038b8-16.</ref>. Cette exclusion vise notamment la théorie platonicienne des Idées. L'interprétation de cet examen, et la question de savoir lequel des trois candidats restants (matière, forme, composé) Aristote retient finalement comme substance première, fait l'objet de débats considérables dans la recherche contemporaine. Comme le résume Christof Rapp, « les controverses interprétatives concernant l'interprétation des livres pertinents s'étendent de la question de savoir jusqu'où s'étend le traitement de l{{'}}''ousia'' (seulement le livre Z, ZH ou même ZHΘ), à la question de la structuration interne du traité de l{{'}}''ousia'', en passant par les questions sur l'objectif précis et la finalité philosophique de ces livres, jusqu'à la question de la nature de l'''eidos'' (la forme) – qui est au cours du traité plusieurs fois désignée comme ''ousia'' – et enfin la question du rapport entre matière et forme et l'unité de l{{'}}''ousia'' qui y est liée »<ref>Christof Rapp et Klaus Corcilius (dir.), ''Aristoteles-Handbuch'', op. cit., chap. IV.18 (traduction libre).</ref>. ==== La forme, candidat à la substantialité ==== Le substrat semble avoir des titres à être appelé substance, puisqu'il est ce dont tout le reste se dit. Mais si l'on identifiait la substance à la matière pure, support ultime de toutes les déterminations, on aboutirait au paradoxe qu'une substance pourrait exister sans aucune détermination<ref>Aristote, ''Métaphysique'', Ζ, 3, 1029a10-26.</ref>. C'est donc la forme (''morphê'', ''eidos'') qui apparaît, dans plusieurs passages des livres centraux, comme substance au sens propre. La forme est ce qui fait qu'une chose est ce qu'elle est, son essence. Elle est aussi acte (''energeia'') par opposition à la matière qui est puissance (''dynamis''). Un morceau d'airain est en puissance une statue ; quand le sculpteur lui a donné la forme de l'Hermès, il est en acte une statue. La forme actualise la matière et lui confère l'être déterminé<ref>Aristote, ''Métaphysique'', Η, 2, 1042b9-1043a28.</ref>. ==== Le composé de matière et de forme ==== La substance concrète, l'individu réel, n'est ni la matière seule ni la forme seule, mais leur composé (''synolon''). Callias n'est ni simplement de la chair et des os, ni simplement la forme de l'homme, mais cet homme-ci composé de cette chair-ci et de ces os-ci structurés selon la forme humaine<ref>Aristote, ''Métaphysique'', Ζ, 10, 1035b14-27.</ref>. La forme demeure cependant première en un sens, car c'est elle qui confère l'unité au composé. Sans la forme, la matière ne serait qu'un amas d'éléments disparates. C'est l'âme, forme du corps vivant, qui fait que ce corps est un organisme uni et non un simple agrégat. Dans l'ordre de la connaissance aussi, c'est par la forme qu'on définit et connaît la substance : on définit l'homme par sa forme (animal rationnel), non par sa matière (chair et os)<ref>Aristote, ''Métaphysique'', Ζ, 11, 1037a21-33. Pour une discussion approfondie, voir Annick Jaulin, ''Aristote. La métaphysique'', op. cit., chap. « La substance ».</ref>. === Puissance et acte (livre Θ) === ==== La distinction de la puissance et de l'acte ==== La distinction entre puissance (''dynamis'') et acte (''energeia'', ''entelecheia'') est l'une des contributions majeures d'Aristote à la philosophie. Elle permet de répondre aux apories de Parménide et de Platon concernant le devenir. Comment une chose peut-elle devenir ce qu'elle n'est pas ? En étant en puissance ce qu'elle devient en acte. Le bronze n'est pas une statue, mais il est en puissance une statue, de sorte qu'il peut le devenir sans passer du non-être à l'être<ref>Aristote, ''Métaphysique'', Θ, 6, 1048a30-1048b17.</ref>. Une chose en puissance n'a pas encore telle propriété, mais elle a la capacité de l'acquérir. La capacité n'est ni l'absence pure (le bronze ne peut pas devenir homme), ni la présence actuelle (ce qui est actuellement chaud n'a plus la capacité de le devenir). Elle est un mode d'être intermédiaire<ref>Aristote, ''Métaphysique'', Θ, 7, 1049a1-18.</ref>. ==== La priorité de l'acte sur la puissance ==== Aristote établit que l'acte est antérieur à la puissance en plusieurs sens : dans l'ordre de la définition (on définit la puissance par l'acte correspondant), dans l'ordre de la connaissance, dans l'ordre de l'essence et du temps<ref>Aristote, ''Métaphysique'', Θ, 8, 1049b10-1050a3.</ref>. Plus profondément, ce qui est éternellement en acte est antérieur à ce qui peut être tantôt en acte, tantôt en puissance. Or les substances divines sont éternellement en acte, tandis que les substances sensibles passent de la puissance à l'acte et de l'acte à la puissance. Les substances immobiles éternelles sont donc absolument premières<ref>Aristote, ''Métaphysique'', Θ, 8, 1050b6-28.</ref>. Comme le souligne Annick Jaulin, cette primauté de l'acte sur la puissance n'est pas un simple point doctrinal mais structure l'ensemble de la philosophie première : « la primauté de l'acte sur la puissance permet de poser l'antériorité de la forme sur la matière », et elle « ouvre également la possibilité d'une interprétation positive du devenir »<ref>Annick Jaulin, ''Aristote. La métaphysique'', op. cit., chap. « La science recherchée ».</ref>. === L'Un et le Multiple (livres I-K) === ==== L'un se dit en plusieurs sens ==== Parallèlement à l'être, l'un aussi se dit en plusieurs sens. Il y a l'un par accident (le musicien et l'homme sont un quand le musicien est homme). Il y a l'un par soi, qui se divise en un par continuité (une route), un par indivisibilité en espèce (tout homme est un par l'espèce), un par le concept et la définition<ref>Aristote, ''Métaphysique'', Ι, 1, 1052a15-1052b14.</ref>. Les pythagoriciens et Platon avaient fait de l'Un un principe suprême, antérieur même à l'être. Aristote critique cette position : l'un n'est pas une substance séparée, mais une propriété convertible avec l'être<ref>Aristote, ''Métaphysique'', Ι, 2, 1053b16-1054a13.</ref>. Dire qu'une chose est et dire qu'elle est une, c'est la même chose. ==== La pluralité et le nombre ==== La pluralité s'oppose à l'un comme le divisible à l'indivisible. Le nombre est une pluralité mesurée par l'un<ref>Aristote, ''Métaphysique'', Ι, 1, 1053a18-24.</ref>. Cette analyse fonde la possibilité des mathématiques et des sciences quantitatives. === Le Premier Moteur immobile (livre Λ) === ==== La démonstration de l'existence du Premier Moteur ==== Le livre Λ de la ''Métaphysique'' constitue le sommet de la philosophie première aristotélicienne. Après avoir établi que les substances sont premières parmi les êtres, Aristote examine quelles substances existent. Il y a évidemment les substances sensibles, soumises au changement. Mais existe-t-il aussi une substance éternelle immobile ?<ref>Aristote, ''Métaphysique'', Λ, 6, 1071b3-5.</ref> La démonstration procède à partir du mouvement. Il existe du mouvement, cela est manifeste. Or tout ce qui est mû est mû par autre chose. Ou bien il y a un premier moteur immobile, ou bien la série des moteurs remonte à l'infini. Mais une série infinie de causes ne peut rien expliquer, car en l'absence d'un premier il n'y aurait ni intermédiaire ni dernier<ref>Aristote, ''Métaphysique'', Λ, 7, 1072a19-1072b4.</ref>. Il doit donc exister un premier moteur qui meut sans être mû. ==== La nature du Premier Moteur ==== Ce Premier Moteur est substance, acte pur, éternel, sans parties, sans grandeur. Il meut comme objet de désir et d'intellection : l'intellect désire le bien et se meut vers lui. Le Premier Moteur, étant le bien suprême et l'intelligible suprême, attire vers lui le premier ciel qui l'imite par son mouvement circulaire éternel<ref>Aristote, ''Métaphysique'', Λ, 7, 1072a23-1072b4.</ref>. Quelle est la vie de ce Premier Moteur ? Il est Pensée, mais que pense-t-il ? Il doit penser ce qu'il y a de plus excellent, donc lui-même. Il est donc Pensée de la Pensée (''noêsis noêseôs noêsis''), intellection qui se pense elle-même dans une contemplation éternellement bienheureuse<ref>Aristote, ''Métaphysique'', Λ, 9, 1074b33-35.</ref>. ==== Les moteurs des sphères ==== Le mouvement des astres requiert, outre le Premier Moteur qui meut la sphère des fixes, une pluralité de moteurs immobiles pour les sphères des planètes. Aristote, se fondant sur l'astronomie d'Eudoxe et de Callippe, compte cinquante-cinq sphères, donc cinquante-cinq moteurs<ref>Aristote, ''Métaphysique'', Λ, 8, 1073a14-1074b14.</ref>. Ces moteurs ne sont pas subordonnés les uns aux autres dans une hiérarchie, mais chacun meut directement sa sphère. Cette pluralité d'intelligences motrices a posé un problème théologique au Moyen Âge : la tradition péripatéticienne et la scolastique médiévale ont tenté de la concilier avec l'unité divine en faisant des moteurs des substances secondes subordonnées au Premier Moteur. La recherche contemporaine, notamment Annick Jaulin, propose une lecture moins théologique : il s'agirait pour Aristote de fournir une explication causale du mouvement astronomique, sans projet théologique transcendant<ref>Annick Jaulin, ''Aristote. La métaphysique'', op. cit., chap. « Les principes et les causes ».</ref>. === Critique de la théorie platonicienne des Idées (livres M-N) === ==== Les arguments contre les Idées séparées ==== Les livres M et N de la ''Métaphysique'' reprennent et développent la critique de la théorie platonicienne des Idées, déjà amorcée dans le livre A. Aristote reconnaît à Platon le mérite d'avoir affirmé l'existence de réalités intelligibles éternelles, mais lui reproche d'avoir séparé ces formes des choses sensibles et d'en avoir fait des substances subsistant par elles-mêmes<ref>Aristote, ''Métaphysique'', Μ, 4-5, 1078b7-1080a8.</ref>. Cette séparation est, selon Aristote, inutile et impossible. Inutile, car les Idées ne servent ni à la connaissance des sensibles, ni à leur existence, ni à leur devenir<ref>Aristote, ''Métaphysique'', Α, 9, 991a8-991b9.</ref>. Impossible, car elle conduit à des apories comme l'argument du Troisième Homme<ref>Aristote, ''Métaphysique'', Α, 9, 990b17-991a8.</ref>. ==== Le statut des êtres mathématiques ==== Platon situait les êtres mathématiques (nombres, figures) dans un plan intermédiaire entre les Idées et les sensibles. Aristote refuse cette ontologie tripartite. Les objets mathématiques n'existent pas séparément, mais par abstraction<ref>Aristote, ''Métaphysique'', Μ, 3, 1078a21-31.</ref>. Le mathématicien considère les sensibles non en tant que sensibles mais en tant que continus ou quantifiés, faisant abstraction de leurs autres propriétés. Cette solution préserve l'objectivité des mathématiques sans multiplier les êtres. Les vérités mathématiques sont nécessaires et universelles parce qu'elles portent sur des propriétés que les choses sensibles possèdent nécessairement, même si ces propriétés ne sont jamais réalisées dans les sensibles avec la perfection qu'étudie le mathématicien<ref>Aristote, ''Métaphysique'', Μ, 2, 1077a9-1077b17.</ref>. == La philosophie pratique : éthique, politique, rhétorique, poétique == === L'''Éthique à Nicomaque'' – La vie bonne === ==== Le souverain bien et le bonheur ==== L'''Éthique à Nicomaque'' s'ouvre sur l'affirmation que toute action et toute recherche vise un bien. Parmi les biens, il y en a un qui est visé pour lui-même et en vue duquel tous les autres sont poursuivis : c'est le souverain bien, que tous appellent bonheur (''eudaimonia'')<ref>Aristote, ''Éthique à Nicomaque'', I, 4, 1095a14-26. Pour une introduction d'ensemble à l'éthique aristotélicienne, voir Gerard J. Hughes, ''Routledge Philosophy Guidebook to Aristotle on Ethics'', Londres, Routledge, 2001 ; Ronald Polansky (éd.), ''The Cambridge Companion to Aristotle's Nicomachean Ethics'', Cambridge, Cambridge University Press, 2014.</ref>. Comme le note Pellegrin, Aristote, dans le premier livre de l'''Éthique à Nicomaque'', « donne l'un des exemples les plus caractéristiques et les plus aboutis du traitement d'une notion léguée par la tradition »<ref>Pierre Pellegrin, ''Dictionnaire Aristote'', op. cit., article « Bonheur (eudaimonia) », p. 37.</ref> : il commence par exposer les opinions reçues sur le bonheur (plaisir, honneurs, richesse, vertu, contemplation), puis examine ce qu'il peut conserver de chacune. Pour déterminer le bonheur véritable, Aristote recourt à la notion de fonction (''ergon'') : le bien pour chaque être réside dans l'accomplissement de sa fonction propre. La fonction propre de l'homme est l'activité de l'âme rationnelle selon l'excellence. Le bonheur est donc « une activité de l'âme selon la vertu parfaite, dans une vie complète »<ref>Aristote, ''Éthique à Nicomaque'', I, 7, 1098a16-18.</ref>. L'interprétation de cette définition fait l'objet d'un débat important dans la recherche contemporaine, qui oppose une lecture « inclusiviste » à une lecture « dominante »<ref>Sur ce débat, voir Gerard J. Hughes, ''Routledge Philosophy Guidebook to Aristotle on Ethics'', op. cit. ; Ronald Polansky (éd.), ''The Cambridge Companion to Aristotle's Nicomachean Ethics'', op. cit. La question fait l'objet d'une discussion approfondie dans la littérature anglo-saxonne, notamment depuis les travaux de J.L. Ackrill et T.H. Irwin.</ref>. Selon la lecture inclusiviste, le bonheur consiste en l'exercice de toutes les vertus, morales et intellectuelles, dans une vie complète. Selon la lecture dominante, le bonheur s'identifie à l'activité de la vertu la plus haute, c'est-à-dire la contemplation, comme le suggère le livre X. Cette tension entre deux perspectives au sein même de l'''Éthique à Nicomaque'' (entre les premiers livres, qui semblent privilégier une vie pratique vertueuse, et le livre X, qui exalte la vie contemplative) reste l'objet d'interprétations divergentes. ==== Les vertus éthiques et la doctrine du juste milieu ==== Aristote distingue les vertus intellectuelles (sagesse théorique, prudence) des vertus éthiques ou morales (courage, tempérance, justice). Les vertus morales sont des dispositions acquises par l'habitude à choisir le juste milieu entre deux extrêmes vicieux<ref>Aristote, ''Éthique à Nicomaque'', II, 6, 1106b36-1107a2.</ref>. Ainsi, le courage est le juste milieu entre la lâcheté (défaut de crainte) et la témérité (excès d'audace). La tempérance est le milieu entre l'insensibilité et l'intempérance. La libéralité est le milieu entre l'avarice et la prodigalité<ref>Aristote, ''Éthique à Nicomaque'', II, 7, 1107a33-1108b10.</ref>. Ce milieu n'est pas arithmétique mais relatif à nous : il dépend des circonstances et de l'agent. Toute l'œuvre de la vertu consiste à trouver ce milieu, guidée par la droite raison (''orthos logos'') qu'incarne la prudence<ref>Aristote, ''Éthique à Nicomaque'', II, 6, 1107a1-2.</ref>. Cette doctrine du juste milieu a connu une fortune durable dans la philosophie morale. Elle est aujourd'hui au centre du renouveau de l'éthique des vertus en philosophie morale contemporaine, à la suite des travaux d'Anscombe, MacIntyre et Nussbaum<ref>Voir les contributions à Ronald Polansky (éd.), ''The Cambridge Companion to Aristotle's Nicomachean Ethics'', op. cit.</ref>. ==== L'amitié ==== Les livres VIII et IX de l'''Éthique à Nicomaque'' traitent de l'amitié (''philia''). Aristote distingue trois espèces d'amitié : l'amitié utile, fondée sur l'intérêt réciproque ; l'amitié agréable, fondée sur le plaisir ; et l'amitié parfaite, celle des hommes vertueux qui s'aiment pour eux-mêmes<ref>Aristote, ''Éthique à Nicomaque'', VIII, 3, 1156a6-1156b7.</ref>. Seule la dernière est véritablement amitié au sens plein, car elle est durable et réciproque. Les deux premières sont instables, car elles cessent quand cesse l'utilité ou le plaisir. L'amitié parfaite requiert du temps et de l'intimité<ref>Aristote, ''Éthique à Nicomaque'', VIII, 6, 1158a10-15.</ref>. L'amitié n'est pas seulement agréable, elle est nécessaire à la vie bonne. L'homme est un animal politique qui ne peut s'accomplir dans l'isolement. L'ami est, selon Aristote, un autre soi-même : en conversant avec lui, en partageant ses joies et ses peines, nous nous connaissons et nous accomplissons nous-mêmes<ref>Aristote, ''Éthique à Nicomaque'', IX, 9, 1169b16-1170a4.</ref>. ==== La vie contemplative ==== Le livre X conclut l'éthique en affirmant que le bonheur suprême réside dans la vie contemplative. L'activité théorétique de l'intellect serait la plus haute et la plus autosuffisante. Elle nous apparente aux dieux, qui passent l'éternité dans la contemplation<ref>Aristote, ''Éthique à Nicomaque'', X, 7, 1177a12-18.</ref>. Aristote reconnaît que cette vie purement intellectuelle dépasse les forces humaines prises isolément, mais affirme que dans la mesure où il y a en nous quelque chose de divin, nous devons tendre vers cette vie<ref>Aristote, ''Éthique à Nicomaque'', X, 7, 1177b30-34.</ref>. Comme le souligne Pellegrin, ce passage du livre X introduit la notion de « bonheur achevé » (''teleia eudaimonia'') et déclare que « le bonheur pratique, celui du citoyen vertueux, n'est bonheur "qu'à titre secondaire" »<ref>Pierre Pellegrin, ''Dictionnaire Aristote'', op. cit., p. 39, citant ''Éthique à Nicomaque'', X, 8, 1178a9.</ref>. La cohérence entre cette thèse et l'analyse de l'''eudaimonia'' développée dans les premiers livres est l'une des questions les plus discutées de l'aristotélisme moral contemporain. === Les ''Politiques'' – La cité et les constitutions === ==== L'homme, animal politique ==== Les ''Politiques'' commencent par affirmer que la cité (''polis'') existe par nature et que l'homme est par nature un animal politique<ref>Aristote, ''Politiques'', I, 2, 1252b27-1253a3. Pour une vue d'ensemble de la pensée politique aristotélicienne, voir Marguerite Deslauriers et Pierre Destrée (éd.), ''The Cambridge Companion to Aristotle's Politics'', Cambridge, Cambridge University Press, 2013.</ref>. Cette affirmation s'oppose à la fois aux sophistes, qui faisaient de la société une convention artificielle, et à la tradition cynique, qui prônait l'autarcie individuelle. L'homme ne peut, selon Aristote, ni vivre ni bien vivre en dehors de la cité, car il a besoin des autres pour satisfaire ses besoins matériels et pour actualiser sa nature rationnelle et morale. La cité n'est pas une simple alliance défensive ou commerciale, mais une communauté de vie bonne, ayant pour fin le bonheur de ses membres<ref>Aristote, ''Politiques'', III, 9, 1280b39-1281a4.</ref>. Il faut signaler ici que les ''Politiques'' contiennent également des thèses aujourd'hui rejetées : la justification de l'esclavage par nature (livre I) et la subordination des femmes ont fait l'objet de critiques nombreuses, déjà au {{XIXe siècle}} et plus encore dans la philosophie féministe contemporaine. Comme le note Larose, « certains des textes [d'Aristote] choqueront à juste titre le lecteur, en particulier ceux qui traitent du statut de la femme et de l'esclavage. Tout philosophe qu'il soit, Aristote est bien un homme de son temps »<ref>Daniel Larose, ''Aristote de A à Z'', op. cit., Introduction.</ref>. ==== Les formes de constitution ==== Aristote distingue six formes de constitution selon deux critères : le nombre de gouvernants (un seul, quelques-uns, la multitude) et la fin poursuivie (l'intérêt commun ou l'intérêt privé des gouvernants). Les formes droites sont la royauté (gouvernement d'un seul pour le bien commun), l'aristocratie (gouvernement de quelques-uns pour le bien commun), et la république ou ''politeia'' (gouvernement de la multitude pour le bien commun). Les formes déviées sont la tyrannie, l'oligarchie et la démocratie au sens péjoratif de démagogie<ref>Aristote, ''Politiques'', III, 7, 1279a22-1279b10.</ref>. Comme le souligne Pellegrin, l'originalité d'Aristote ne réside pas dans la quête d'une constitution idéale unique, mais dans l'idée qu'« une constitution est dite droite si elle gouverne la cité en vue de "l'avantage commun" »<ref>Pierre Pellegrin, ''Dictionnaire Aristote'', op. cit., article « Politique », p. 57.</ref>. Aucune constitution n'est absolument la meilleure en tout lieu et en tout temps : la meilleure constitution pour une cité donnée dépend des circonstances – étendue du territoire, richesses, mœurs des habitants. Aristote fait preuve ici d'un réalisme politique qui contraste avec l'utopisme de la ''République'' de Platon. ==== La constitution mixte et la classe moyenne ==== La constitution la plus stable, selon le livre IV, est celle qui mêle des éléments oligarchiques et démocratiques, donnant le pouvoir à la classe moyenne<ref>Aristote, ''Politiques'', IV, 11, 1295a25-1295b1.</ref>. Les classes moyennes ne sont ni assez pauvres pour envier les riches, ni assez riches pour mépriser les pauvres. Elles recherchent la stabilité et l'égalité plutôt que les révolutions<ref>Aristote rappelle aussi que « les bons législateurs sont sortis de la classe moyenne », citant l'exemple de Solon, Lycurgue et Charondas. Voir Daniel Larose, ''Aristote de A à Z'', op. cit., article « Constitution mixte ».</ref>. Cette théorie de la classe moyenne et de la constitution mixte a influencé la pensée politique occidentale, de Polybe à Montesquieu. La méthode aristotélicienne, comme le souligne Pellegrin, ne se contente pas d'observer la diversité des constitutions, mais « rend la diversité intelligible en en construisant un modèle théorique » à partir d'un nombre fini de paramètres<ref>Pierre Pellegrin, ''Dictionnaire Aristote'', op. cit., p. 60.</ref>. ==== L'éducation ==== Les derniers livres des ''Politiques'' sont consacrés à l'éducation. Celle-ci ne peut être laissée aux familles privées, car la cité a pour fin de rendre les citoyens vertueux. Il doit donc y avoir une éducation publique commune, qui forme le caractère et l'intelligence des jeunes en vue de la vie civique et de la vie bonne<ref>Aristote, ''Politiques'', VIII, 1, 1337a11-21.</ref>. Cette éducation comprend la gymnastique pour le corps, la musique pour l'âme, les lettres pour l'usage pratique, et le dessin pour apprécier la beauté des œuvres. Mais son but ultime n'est pas l'utilité mais le loisir bien employé (''scholê''), c'est-à-dire l'activité libre de l'esprit<ref>Aristote, ''Politiques'', VIII, 3, 1337b28-1338a13.</ref>. === La ''Rhétorique'' – L'art de persuader === ==== La rhétorique comme art ==== La ''Rhétorique'' étudie les moyens de persuasion dans tous les genres de discours. Elle est l'homologue (''antistrophos'') de la dialectique : de même que la dialectique enseigne à raisonner sur toute question à partir de prémisses probables, la rhétorique enseigne à persuader tout auditoire sur tout sujet<ref>Aristote, ''Rhétorique'', I, 1, 1354a1-11.</ref>. Aristote prend ses distances avec la critique platonicienne de la rhétorique formulée dans le ''Gorgias''. Bien utilisée, la rhétorique est un art (''technê'') légitime. Elle sert à défendre la vérité et la justice, car « il serait absurde que l'incapacité de se servir de ses forces corporelles soit honteuse, mais non l'incapacité de se servir de la parole »<ref>Aristote, ''Rhétorique'', I, 1, 1355a20-24.</ref>. ==== Les trois genres rhétoriques ==== Aristote distingue trois genres de rhétorique. Le genre délibératif conseille ou dissuade concernant l'avenir (devons-nous faire la guerre ?). Le genre judiciaire accuse ou défend concernant le passé (X a-t-il commis ce crime ?). Le genre épidictique loue ou blâme concernant le présent (célébrons la vertu du héros)<ref>Aristote, ''Rhétorique'', I, 3, 1358b2-29.</ref>. Chaque genre a ses lieux propres. Le délibératif porte sur l'utile et le nuisible. Le judiciaire porte sur le juste et l'injuste. L'épidictique porte sur le beau et le laid moral<ref>Aristote, ''Rhétorique'', I, 3, 1358b29-1359a5.</ref>. ==== Les preuves rhétoriques : ethos, pathos, logos ==== Aristote distingue trois moyens de persuasion : l'''ethos'' (le caractère de l'orateur), le ''pathos'' (les émotions de l'auditoire), et le ''logos'' (le raisonnement lui-même)<ref>Aristote, ''Rhétorique'', I, 2, 1356a1-13.</ref>. L'''ethos'' persuade en inspirant confiance : l'orateur doit paraître prudent, vertueux et bienveillant. Le ''pathos'' persuade en suscitant des émotions favorables à la thèse défendue. Le livre II étudie systématiquement les émotions (colère, pitié, crainte, envie), leurs causes et leurs manifestations. Le ''logos'' persuade par les raisonnements, notamment l'enthymème (syllogisme rhétorique à partir de prémisses probables) et l'exemple (induction rhétorique)<ref>Aristote, ''Rhétorique'', I, 2, 1356a35-1356b5.</ref>. Cette analyse fonde la rhétorique comme discipline rationnelle et systématique. Elle constituera la base de l'enseignement rhétorique jusqu'à l'époque moderne et nourrit aujourd'hui des recherches actives en philosophie de l'argumentation et théorie de la communication. === La ''Poétique'' – La création artistique === ==== La ''mimêsis'' et la ''catharsis'' ==== La ''Poétique'' étudie l'art poétique, principalement la tragédie et l'épopée. La poésie, comme tous les arts, est imitation (''mimêsis''). Mais elle n'imite pas les choses telles qu'elles sont (c'est le rôle de l'histoire), mais telles qu'elles pourraient ou devraient être selon la vraisemblance ou la nécessité<ref>Aristote, ''Poétique'', 9, 1451a36-1451b5.</ref>. C'est pourquoi la poésie est, selon Aristote, plus philosophique que l'histoire : elle dit l'universel, alors que l'histoire dit le particulier. La tragédie représente une action sérieuse et complète, en suscitant pitié et crainte, pour opérer la purification (''catharsis'') de ces émotions<ref>Aristote, ''Poétique'', 6, 1449b24-28.</ref>. La doctrine de la ''catharsis'' est l'une des plus discutées de toute la philosophie ancienne. L'interprétation médicale (la tragédie « purge » les émotions excessives) a été défendue notamment par Jacob Bernays au {{XIXe siècle}} ; d'autres interprétations y voient plutôt une « clarification » morale ou cognitive des émotions<ref>Sur ces interprétations divergentes, voir Stephen Halliwell, ''Aristotle's Poetics'', Londres, Duckworth, 1986 ; Christof Rapp, « Katharsis der Emotionen », dans B. Seidensticker et M. Vöhler (éd.), ''Katharsiskonzeptionen vor Aristoteles'', Berlin, De Gruyter, 2007 ; Christof Rapp, « Aristoteles über das Wesen und die Wirkung der Tragödie », dans O. Höffe (éd.), ''Aristoteles. Poetik'', Berlin, Akademie Verlag, 2009. La question demeure ouverte dans la recherche contemporaine.</ref>. ==== La structure de la tragédie ==== Aristote analyse avec précision la structure de la tragédie. Celle-ci doit former un tout avec commencement, milieu et fin. L'intrigue (''mythos'') est l'âme de la tragédie, plus importante que les caractères<ref>Aristote, ''Poétique'', 11, 1452a15-32.</ref>. L'intrigue idéale comprend une péripétie (''peripeteia''), renversement de l'action en sens contraire, et une reconnaissance (''anagnôrisis''), passage de l'ignorance à la connaissance. Le héros tragique ne doit être ni parfaitement vertueux ni complètement vicieux, mais occuper une position intermédiaire. Son malheur doit résulter non d'un vice mais d'une erreur (''hamartia'')<ref>Aristote, ''Poétique'', 13, 1453a7-10.</ref>. Ainsi Œdipe qui, sans le savoir, tue son père et épouse sa mère, inspire-t-il pitié plutôt que répulsion. Comme le rappelle Pellegrin, la ''Poétique'' que nous connaissons est une œuvre incomplète : « nous ne possédons qu'une partie, celle qu'Aristote a consacrée à la tragédie, et en partie à l'épopée »<ref>Pierre Pellegrin, ''Dictionnaire Aristote'', op. cit., p. 109.</ref>. La partie consacrée à la comédie est perdue. Ces analyses ont profondément influencé la théorie littéraire occidentale, notamment à la Renaissance avec la redécouverte du traité, et nourrissent encore les débats en théorie littéraire et esthétique contemporaine. == Réception et postérité == L'œuvre d'Aristote a connu une transmission complexe et une réception multiforme dans l'histoire de la pensée. Dans l'Antiquité tardive, les commentateurs néoplatoniciens (Porphyre, Alexandre d'Aphrodise, Simplicius, Philopon) étudient et expliquent ses œuvres, en cherchant souvent à les harmoniser avec le platonisme. Au IXe siècle, les philosophes et savants du monde arabo-musulman traduisent Aristote en syriaque puis en arabe. Al-Fârâbî (872-950), Avicenne (980-1037) et Averroès (1126-1198), surnommé « le Commentateur », développent un aristotélisme islamique qui sera lui-même reçu en Occident latin. À partir du XIIe siècle, les œuvres d'Aristote, accompagnées des commentaires arabes, sont traduites en latin et transforment l'enseignement universitaire médiéval. Albert le Grand (1200-1280) et Thomas d'Aquin (1225-1274) intègrent largement Aristote dans la théologie chrétienne, montrant la possibilité d'une articulation entre la philosophie péripatéticienne et la foi. Le thomisme deviendra une philosophie de référence dans l'Église catholique. Cette domination scolastique provoquera, à la Renaissance et à l'époque moderne, des remises en cause variées. La science moderne, de Galilée à Newton, se construit en partie en réaction à la physique aristotélicienne, mais sans en constituer la simple négation : la recherche récente sur la philosophie naturelle de la Renaissance et la scolastique tardive décrit un tableau plus nuancé, fait à la fois de continuités, de relectures et d'oppositions<ref>Voir notamment, pour les débats sur la transition entre l'aristotélisme tardif et la science moderne, A.C. Bowen et C. Wildberg (éd.), ''New Perspectives on Aristotle's De Caelo'', op. cit. ; et l'entrée « Aristotle's Natural Philosophy » de la ''Stanford Encyclopedia of Philosophy''.</ref>. Au {{XXe siècle}} et au début du {{XXIe siècle}}, plusieurs domaines de la pensée aristotélicienne ont fait l'objet d'un regain d'attention universitaire. En philosophie morale, le renouveau de l'éthique des vertus (Anscombe, MacIntyre, Nussbaum) a remis au premier plan les analyses de l'''Éthique à Nicomaque''<ref>Voir Ronald Polansky (éd.), ''The Cambridge Companion to Aristotle's Nicomachean Ethics'', op. cit. ; Gerard J. Hughes, ''Routledge Philosophy Guidebook to Aristotle on Ethics'', op. cit.</ref>. En philosophie de l'esprit, certains philosophes ont vu dans l'hylémorphisme aristotélicien une alternative aux apories du dualisme cartésien et du réductionnisme matérialiste. En métaphysique, la conception de la substance et des catégories nourrit les débats sur l'ontologie réaliste. En philosophie de la biologie, la téléologie et la conception de l'organisme comme totalité fonctionnelle font l'objet de relectures<ref>Voir notamment Allan Gotthelf, ''Teleology, First Principles, and Scientific Method in Aristotle's Biology'', op. cit. ; James G. Lennox, ''Aristotle's Philosophy of Biology'', op. cit. ; Sophia M. Connell (éd.), ''The Cambridge Companion to Aristotle's Biology'', op. cit.</ref>. Au-delà de ces actualisations, l'œuvre d'Aristote demeure un objet d'étude central de l'histoire de la philosophie. Comme le rappelle Daniel Larose, « il existe ainsi plusieurs "Aristote" selon les époques » : la pluralité des lectures (logicienne, physicienne, métaphysicienne, théologienne, éthicienne) reflète à la fois la richesse interne de son œuvre et la diversité des projets philosophiques qui s'en sont nourris<ref>Daniel Larose, ''Aristote de A à Z'', op. cit., Introduction.</ref>. La recherche universitaire actuelle aborde le corpus aristotélicien comme un ensemble cohérent sans pour autant le juger systématique au sens classique du terme. == Notes et références == {{Références|colonnes = 2}} == Bibliographie == === Éditions et traductions de référence === * Pierre Pellegrin (dir.), ''Aristote. Œuvres complètes'', Paris, Flammarion, 2014. * Bekker, Immanuel (éd.), ''Aristotelis Opera'', Berlin, Reimer, 1831-1870. * Jonathan Barnes (éd.), ''The Complete Works of Aristotle. The Revised Oxford Translation'', Princeton, Princeton University Press, 1984. * Aristote, ''Catégories'', éd. et trad. R. Bodéüs, Paris, Les Belles Lettres, 2001. * Aristote, ''Topiques I-IV'', éd. et trad. J. Brunschwig, Paris, Les Belles Lettres, 1967 et 2007. * Aristote, ''Poétique'', éd. et trad. J. Hardy, Paris, Les Belles Lettres. * Aristote, ''De l'Âme'', éd. et trad. A. Jannone et E. Barbotin, Paris, Les Belles Lettres. * Aristote, ''Politique'', éd. et trad. J. Aubonnet, Paris, Les Belles Lettres, 1960 et suiv. === Synthèses générales === * Pierre Aubenque, ''Le problème de l'être chez Aristote'', Paris, PUF, 1962. * Jonathan Barnes (éd.), ''The Cambridge Companion to Aristotle'', Cambridge, Cambridge University Press, 1995. * Werner Jaeger, ''Aristote. Fondements pour une histoire de son évolution'', trad. fr. O. Sedeyn, Paris, L'Éclat, 1997 (1923). * Daniel Larose, ''Aristote de A à Z'', Paris, PUF, « Que sais-je ? », 2021. * Pierre Pellegrin, ''Dictionnaire Aristote'', Paris, Ellipses, 2007. * Pierre Pellegrin, ''Aristote'', Paris, Bordas, 1990. * David Ross, ''Aristotle'', London, Methuen, 1923. === Études spécialisées par domaine === * Logique et épistémologie : Suzanne Husson (éd.), ''Interpréter le De Interpretatione'', Paris, Vrin, 2009 ; Jan Lukasiewicz, ''La syllogistique d'Aristote'', trad. fr., Paris, Vrin ; J. Brunschwig, ''Aristote: Topiques I-IV'', Paris, Les Belles Lettres, 1967 et 2007 ; L.A. Dorion, ''Les Réfutations Sophistiques d'Aristote'', Paris, Vrin, 1995. * Métaphysique : Vasilis Politis, ''Routledge Philosophy GuideBook to Aristotle and the Metaphysics'', Londres-New York, Routledge, 2004 ; Annick Jaulin, ''Aristote. La métaphysique'', Paris, PUF, 1999 ; Maddalena Bonelli (dir.), ''Physique et métaphysique chez Aristote'', Paris, Vrin ; Michel Narcy et Alonso Tordesillas (dir.), ''La « Métaphysique » d'Aristote. Perspectives contemporaines'', Paris, Vrin ; Richard Bodéüs, ''Aristote et la théologie des vivants immortels'', Montréal-Paris, Bellarmin-Les Belles Lettres, 1992. * Philosophie de la nature et biologie : Sophia M. Connell (éd.), ''The Cambridge Companion to Aristotle's Biology'', Cambridge, Cambridge University Press, 2021 ; James G. Lennox, ''Aristotle's Philosophy of Biology: Studies in the Origins of Life Science'', Cambridge, Cambridge University Press, 2001 ; Allan Gotthelf, ''Teleology, First Principles, and Scientific Method in Aristotle's Biology'', Oxford, Oxford University Press, 2012 ; Allan Gotthelf et James G. Lennox (éd.), ''Philosophical Issues in Aristotle's Biology'', Cambridge, Cambridge University Press, 1987 ; A.C. Bowen et C. Wildberg (éd.), ''New Perspectives on Aristotle's De Caelo'', Leiden, Brill, 2009. * Éthique et politique : Ronald Polansky (éd.), ''The Cambridge Companion to Aristotle's Nicomachean Ethics'', Cambridge, Cambridge University Press, 2014 ; Gerard J. Hughes, ''Routledge Philosophy Guidebook to Aristotle on Ethics'', Londres, Routledge, 2001 ; Marguerite Deslauriers et Pierre Destrée (éd.), ''The Cambridge Companion to Aristotle's Politics'', Cambridge, Cambridge University Press, 2013 ; Pierre-Marie Morel, ''Aristote. Une philosophie de l'activité'', Paris, Flammarion, 2003. * Synthèses de référence en langue allemande et anglaise : Christopher Shields (éd.), ''The Oxford Handbook of Aristotle'', Oxford, Oxford University Press, 2012 ; Christof Rapp et Klaus Corcilius (dir.), ''Aristoteles-Handbuch. Leben – Werk – Wirkung'', Stuttgart, Metzler, 2011. ===L{{'}}''Organon''=== *Bronstein, David, ''Aristotle on Knowledge and Learning: The Posterior Analytics'', Oxford, Oxford University Press, 2016. *Dorion, Louis-André, ''Les Réfutations Sophistiques d’Aristote'', Paris, Vrin, 1995. *Harari, Orna, ''Knowledge and Demonstration: Aristotle’s Posterior Analytics'', Dordrecht, Springer / Kluwer, 2004. *Husson, Suzanne (éd.), ''Interpréter le'' De Interpretatione, Paris, Vrin, 2009. *Lear, Jonathan, ''Aristotle and Logical Theory'', Cambridge, Cambridge University Press, 1980. *Mesquita, António Pedro, et Santos, Ricardo (éd.), ''New Essays on Aristotle’s Organon'', Abingdon-Oxon, Routledge, 2024 (contributions notamment de Crivelli, Pellegrin, Bronstein/Zuppolini, Fait). *Patterson, Richard, ''Aristotle’s Modal Logic: Essence and Entailment in the Organon'', Cambridge, Cambridge University Press, 1995. *Whitaker, C. W. A., ''Aristotle’s'' De Interpretatione: ''Contradiction and Dialectic'', Oxford, Clarendon Press, 1996. === Ressources en ligne === * ''Stanford Encyclopedia of Philosophy'', entrées : « Aristotle » ; « Aristotle's Categories » ; « Aristotle's Logic » ; « Aristotle's Metaphysics » ; « Aristotle's Ethics » ; « Aristotle's Political Philosophy » ; « Aristotle's Natural Philosophy » ; « Aristotle's Biology » ; « Aristotle's Aesthetics ». [https://plato.stanford.edu/] * ''Internet Encyclopedia of Philosophy'', entrée « Aristotle ». [https://iep.utm.edu/aristotle/] {{Autocat}} [[Catégorie:Philosophe]] {{DEFAULTSORT:Aristote}} jiy5c5wnawocrxzr62e8580s6o9ku7k Le mouvement Wikimédia/Le World Wide Web 0 83388 765189 764399 2026-04-27T07:48:30Z Lionel Scheepmans 20012 765189 wikitext text/x-wiki <noinclude>{{Le mouvement Wikimédia}}</noinclude>Maintenant que le lien entre la création d'Internet et le mouvement Wikimédia est établi, découvrons à présent l'application la plus connue du réseau, que l'on nomme le ''[[w:World Wide Web|World Wide Web]]'', ou plus simplement ''« Web »''. C'est [[w:fr : Tim Berners-Lee|Tim Berners-Lee]] qui en fut l’inventeur, lorsqu’il était encore actif à l'[[w:Organisation_européenne_pour_la_recherche_nucléaire|Organisation européen pour la recherche nucléaire]]. Il avait pour idée de créer un espace d’échange public par l’intermédiaire d'Internet, et pour y parvenir, il mit au point le logiciel « [[w:fr:WorldWideWeb|''WorldWideWeb'']] », rebaptisé Nexus pour éviter toute confusion avec le ''World Wide Web''<ref>{{Lien web|langue=|auteur=W3C|titre=Tim Berners-Lee : WorldWideWeb, the first Web client|url=https://web.archive.org/web/20201104024350/http://www.w3.org/People/Berners-Lee/WorldWideWeb.html|site=|consulté le=}}.</ref>[[Fichier:Sir Tim Berners-Lee (cropped).jpg|vignette|<small>Figure 11. Tim Berners-Lee en 2014.</small>]] Grâce à un système d’indexation appelé [[w:Hypertexte|hypertexte]], ce programme informatique a permis de produire et de connecter des espaces numériques intitulés sites Web. Ceux-ci sont composés de pages web, hébergées sur des ordinateurs distants, mais connectés entre eux au travers du réseau Internet. Pour permettre ce type de connexion, Berners-Lee mit au point le ''[[w:fr : Hypertext Transfer Protocol|Hypertext Transfer Protocol]]'' ou HTTP, un nouveau protocole de communication simple en soi, mais dont la mise en œuvre technique est compliquée. Pour veiller au bon fonctionnement et au bon usage de l'espace web, des règles de standardisation ont tout d'abord été édictées par l’association ''[[w:fr : Internet Society|Internet Society]]''. Après quoi, Berners-Lee fonda le [[w:W3C|W3C]], un consortium international dont la devise est : « un seul Web partout et pour tous »<ref>{{Lien web|langue=|auteur=W3C|titre=La mission du W3C|url=https://web.archive.org/web/20201031040456/https://www.w3c.fr/a-propos-du-w3c-france/la-mission-du-w3c/|site=|date=|consulté le=}}.</ref>. Si ce slogan nous apparaît très naturel aujourd’hui, il faut toutefois savoir que l'espace Web a bien failli être géré séparément par des acteurs commerciaux, avec tous les droits d'accès que cela aurait pu engendrer. À partir du trente avril 1993, jour du dépôt du logiciel WorldWideWeb dans le [[Le domaine public|domaine public]] par [[w:Robert Cailliau|Robert Cailliau]], un collègue de Berners-Lee chargé de la promotion de son projet, un tel scénario était en effet possible. Sauf qu'après le départ de Berners-Lee, devenu président du W3C, [[w:François Flückiger|François Flückiger]], qui avait repris son poste au sein du [[w:Organisation_européenne_pour_la_recherche_nucléaire|CERN]]<ref>{{Ouvrage|auteur1=James Gillies|auteur2=Robert Cailliau|titre=How the Web Was Born – The Story of the World Wide Web|éditeur=Oxford University Press|date=septembre 2000|pages totales=372|isbn=978-0-19-286207-5}}.</ref>, eut la présence d'esprit de réagir à temps. Selon le livre ''Alexandria'' qui parcourt l'histoire de Robert Caillau''<ref>{{Ouvrage|langue=|auteur=|prénom1=Quentin|nom1=Jardon|titre=Alexandria : les pionniers oubliés du web : récit|passage=154|lieu=Paris|éditeur=Gallimard|date=2019|pages totales=|isbn=978-2-07-285287-9|oclc=1107518440}}.</ref>'', voici ce qui aurait pu se passer si le code de l’éditeur HTML n'avait finalement pas été placé sous licence libre. <blockquote> La philanthropie de Robert, c’est très sympa, mais ça expose le Web à d’horribles dangers. Une entreprise pourrait s’emparer du code source, corriger un minuscule bug, s’approprier le « nouveau » logiciel et enfin faire payer une licence à ses utilisateurs. L’ogre Microsoft, par exemple, serait du genre à flairer le bon plan pour écraser son ennemi Macintosh. Les détenteurs d’un PC devraient alors débourser un certain montant pour profiter des fonctionnalités du Web copyrighté Microsoft. Les détenteurs d’un Macintosh, eux, navigueraient sur un Web de plus en plus éloigné de celui vendu par Bill Gates, d’abord gratuit peut-être, avant d’être soumis lui aussi à une licence. </blockquote> Face à un tel scénario, nous découvrons de nouveau à quel point le concept de licence libre a fondamentalement changé le cours de la révolution numérique. Sans cela, nos expériences et nos usages de l'espace numérique auraient été totalement différents. L'utopie Wikipédia, par exemple, n'aurait certainement pas vu le jour, en raison de l'éclatement des espaces numériques et des coûts d'accès auxquels seraient confrontés les bénévoles qui ont construit le projet. Quoi qu'il en soit, et au niveau technique, une fois l'espace web apparu, il ne manquait plus qu'une chose pour permettre la création d'une encyclopédie collaborative au format numérique.{{AutoCat}} 9tg0hhc0l1q12pkbd8lotmz74z8v2l6 Mathc initiation/004y 0 83655 765198 764739 2026-04-27T11:10:50Z Xhungab 23827 765198 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] : [[Mathc initiation/005i| Sommaire]] : {{Partie{{{type|}}}| Le théorème de Stoke (version I) }} En mathématiques, et plus particulièrement en géométrie différentielle, le théorème de Stokes est un résultat central sur l'intégration des formes différentielles, qui généralise le second théorème fondamental de l'analyse, ainsi que de nombreux théorèmes d'analyse vectorielle. [https://fr.khanacademy.org/math/multivariable-calculus/greens-theorem-and-stokes-theorem/stokes-theorem/v/stokes-theorem-intuition Khanacademy : stokes-theorem-intuition] ... [https://fr.khanacademy.org/math/multivariable-calculus/greens-theorem-and-stokes-theorem/stokes-proof/v/stokes-theorem-proof-part-1 Khanacademy : stokes-theorem-proof] Le théorème de Stoke (version I) // // || || || (curl F).n dS = || (curl F).(-f_xi-f_yj+k) dA || || // // S S Copier la bibliothèque dans votre répertoire de travail : * [[Mathc initiation/0057|x_afile.h ............ Déclaration des fichiers h]] * [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]] * [[Mathc initiation/Fichiers c : c47ca|x_strcp.h ........... Déclaration des structures (points, vecteurs)]] * [[Mathc initiation/Fichiers h : c23a3|x_fx.h ................ Calculer les dérivées]] * [[Mathc initiation/Fichiers h : c25a4|x_fxy.h]] * [[Mathc initiation/Fichiers h : c26a4|x_fxyz.h]] * [[Mathc initiation/Fichiers h : c59a7|x_l3d_dx.h ......... L'intégrale curviligne 3d]] * [[Mathc initiation/Fichiers h : c59a8|x_l3d_dy.h ]] * [[Mathc initiation/Fichiers h : c59a9|x_l3d_dz.h ]] * [[Mathc initiation/004z|x_prods.h ........... u = (-f_x i, -f_y j, 1 k) ]] * [[Mathc initiation/Fichiers h : c59ab|x_curl.h ............. Calculer le rotationel ]] * [[Mathc initiation/0050|x_stokxy.h ........... L'intégrale de Stoke ]] * [[Mathc initiation/0051|x_stokyx.h]] les fonctions f : * [[Mathc initiation/Fichiers h : c59fa|f.h]] Résolution avec : * [[Mathc initiation/0052|c0a1.c .............. L'intégrale de Stoke dxdy .... s = 113.081]] * [[Mathc initiation/a470|c0a2.c .............. Les intégrales curviligne ...... s = +113.097]] * [[Mathc initiation/0053|c0a3.c .............. L'intégrale de Stoke '''dydx''' .... s = 113.081]] * [[Mathc initiation/0054|c0b1.c .............. L'intégrale de Stoke dxdy .... s = -12.579]] * [[Mathc initiation/Fichiers c : c59cb2|c0b2.c .............. Les intégrales curviligne ...... s = -12.566]] * [[Mathc initiation/0058|c0b3.c .............. L'intégrale de Stoke '''dydx''' .... s = -12.579]] Regardons la fonction qui effectue le travail : * [[Mathc initiation/0056| Étudions la fonction '''stokes_dxdy();''']] {{AutoCat}} dyqml1wtubicft1eczmaqhlgn8j2eq1 Mathc initiation/005g 0 83672 765193 764741 2026-04-27T10:29:33Z Xhungab 23827 765193 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] : [[Mathc initiation/005i| Sommaire]] : {{Partie{{{type|}}}| Le théorème de Stoke (version III) }} En mathématiques, et plus particulièrement en géométrie différentielle, le théorème de Stokes est un résultat central sur l'intégration des formes différentielles, qui généralise le second théorème fondamental de l'analyse, ainsi que de nombreux théorèmes d'analyse vectorielle. [https://fr.khanacademy.org/math/multivariable-calculus/greens-theorem-and-stokes-theorem/stokes-theorem/v/stokes-theorem-intuition Khanacademy : stokes-theorem-intuition] ... [https://fr.khanacademy.org/math/multivariable-calculus/greens-theorem-and-stokes-theorem/stokes-proof/v/stokes-theorem-proof-part-1 Khanacademy : stokes-theorem-proof] Copier la bibliothèque dans votre répertoire de travail : * [[Mathc initiation/005a|x_afile.h ............ Déclaration des fichiers h]] * [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]] * [[Mathc initiation/Fichiers c : c47ca|x_strcp.h ........... Déclaration des structures (points, vecteurs)]] * [[Mathc initiation/Fichiers h : c26a4|x_fxyz.h]] * [[Mathc initiation/Fichiers h : c59ab|x_curl.h ............. Calculer le rotationel ]] * [[Mathc initiation/005b|x_stokxy.h ........... L'intégrale de Stoke ]] * [[Mathc initiation/005c|x_stokyx.h]] les fonctions f : * [[Mathc initiation/005d|f.h]] Résolution avec : * [[Mathc initiation/005e|c0a1.c .............. L'intégrale de Stoke dxdy]] * [[Mathc initiation/005f|c0b1.c .............. L'intégrale de Stoke dydx]] {{AutoCat}} 7rq8jhg53p9p0abka01yofw1dgqcoju 765197 765193 2026-04-27T11:09:22Z Xhungab 23827 765197 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc initiation (livre)]] : [[Mathc initiation/005i| Sommaire]] : {{Partie{{{type|}}}| Le théorème de Stoke (version III) }} En mathématiques, et plus particulièrement en géométrie différentielle, le théorème de Stokes est un résultat central sur l'intégration des formes différentielles, qui généralise le second théorème fondamental de l'analyse, ainsi que de nombreux théorèmes d'analyse vectorielle. [https://fr.khanacademy.org/math/multivariable-calculus/greens-theorem-and-stokes-theorem/stokes-theorem/v/stokes-theorem-intuition Khanacademy : stokes-theorem-intuition] ... [https://fr.khanacademy.org/math/multivariable-calculus/greens-theorem-and-stokes-theorem/stokes-proof/v/stokes-theorem-proof-part-1 Khanacademy : stokes-theorem-proof] Le théorème de Stoke (version III) Dans cette version on a déterminé le vecteur u géométriquement. Il est introduit en début du fichier *.c par : v3d u = {1,2,3}; // // || || || (curl F).n dS = || (curl F).(-(u.i), -(u.j), 1) dA = || || // // S S Copier la bibliothèque dans votre répertoire de travail : * [[Mathc initiation/005a|x_afile.h ............ Déclaration des fichiers h]] * [[Mathc initiation/Fichiers h : c30a2|x_def.h .............. Déclaration des utilitaires]] * [[Mathc initiation/Fichiers c : c47ca|x_strcp.h ........... Déclaration des structures (points, vecteurs)]] * [[Mathc initiation/Fichiers h : c26a4|x_fxyz.h]] * [[Mathc initiation/Fichiers h : c59ab|x_curl.h ............. Calculer le rotationel ]] * [[Mathc initiation/005b|x_stokxy.h ........... L'intégrale de Stoke ]] * [[Mathc initiation/005c|x_stokyx.h]] les fonctions f : * [[Mathc initiation/005d|f.h]] Résolution avec : * [[Mathc initiation/005e|c0a1.c .............. L'intégrale de Stoke dxdy]] * [[Mathc initiation/005f|c0b1.c .............. L'intégrale de Stoke dydx]] {{AutoCat}} ghyb52367w1nn5gpr273cbi2h4lu7i4 Mathc initiation/005a 0 83673 765194 761523 2026-04-27T10:30:04Z Xhungab 23827 765194 wikitext text/x-wiki [[Catégorie:Mathc initiation (livre)]] [[Mathc initiation/005g| Sommaire]] Installer ce fichier dans votre répertoire de travail. {{Fichier|x_afile.h|largeur=70%|info=|icon=Crystal Clear mimetype source h.png}} <syntaxhighlight lang="c"> /* ---------------------------------- */ /* save as x_afile.h */ /* ---------------------------------- */ #include <stdio.h> #include <stdlib.h> #include <ctype.h> #include <time.h> #include <math.h> #include <string.h> /* ---------------------------------- */ #include "x_def.h" #include "x_strcp.h" /* ---------------------------------- */ #include "x_fxyz.h" /* ---------------------------------- */ #include "x_curl.h" /* ---------------------------------- */ #include "x_stokxy.h" #include "x_stokyx.h" /* ---------------------------------- */ /* ---------------------------------- */ </syntaxhighlight> {{AutoCat}} pc3b19guxcj2hqphsl49ate2lsqy2dy Discussion utilisateur:~2026-25563-57 3 83866 765191 2026-04-27T10:07:08Z JackPotte 5426 Page créée avec « {{subst:Test 2}}~~~~ » 765191 wikitext text/x-wiki {|class="WSerieH" class="plainlinks" id="vandale" align="center" style="width:100%;margin-bottom:2em;border:1px solid #8888aa;border-right-width:2px;border-bottom-width:2px;background-color:#f7f8ff;padding:5px;text-align:justify" |- |[[Image:Nuvola apps important.svg|64px|Stoppez le vandalisme !]] |Bonjour '''{{BASEPAGENAME}}''', Merci de ne '''plus''' effectuer de modifications non pertinentes sur Wikilivres, car cela est considéré comme du [[w:vandalisme|vandalisme]] et peut être réprimé, notamment par un blocage de votre accès. Si vous voulez vous familiariser avec le projet, veuillez consulter la [[Wikilivres:Présentation|page de présentation]]. |} [[Catégorie:Vandales avertis]][[Utilisateur:JackPotte|JackPotte]] ([[Discussion utilisateur:JackPotte|<span style="color:#FF6600">$</span>♠]]) 27 avril 2026 à 12:07 (CEST) 6mb7u6scxqaefbmfyw22azx0400dk76 765192 765191 2026-04-27T10:08:33Z JackPotte 5426 765192 wikitext text/x-wiki {|class="WSerieH" class="plainlinks" id="vandale" align="center" style="width:100%;margin-bottom:2em;border:1px solid #8888aa;border-right-width:2px;border-bottom-width:2px;background-color:#f7f8ff;padding:5px;text-align:justify" |- |[[Image:Nuvola apps important.svg|64px|Stoppez le vandalisme !]] |Bonjour '''{{BASEPAGENAME}}''', Merci de ne '''plus''' effectuer de modifications non pertinentes sur Wikilivres, car cela est considéré comme du [[w:vandalisme|vandalisme]] et peut être réprimé, notamment par un blocage de votre accès. Si vous voulez vous familiariser avec le projet, veuillez consulter la [[Wikilivres:Présentation|page de présentation]]. |} Aucune réponse à [[Discussion utilisateur:~2026-25430-78]], répété en commentaire d'édition, et passage en force. [[Catégorie:Vandales avertis]][[Utilisateur:JackPotte|JackPotte]] ([[Discussion utilisateur:JackPotte|<span style="color:#FF6600">$</span>♠]]) 27 avril 2026 à 12:07 (CEST) eosl2ep6u0pr58y5v3wnm35mkzx7hjd