- À l’époque du PDP-11, C correspondait bien à l’abstraction matérielle, mais sur les CPU modernes, la machine abstraite de C — exécution séquentielle et mémoire plate — diverge fortement du matériel réel
- Spectre et Meltdown sont liés au fait que, pour exécuter rapidement le modèle séquentiel de C, les processeurs se reposent fortement sur la prédiction de branchement, l’exécution spéculative et le parallélisme au niveau des instructions
- Pour produire du code C rapide, il ne suffit pas d’une simple traduction en langage machine : il faut des optimisations complexes à l’échelle de LLVM/Clang, et certaines peuvent entrer en conflit avec la sémantique de C
- Des règles comme la provenance des pointeurs, le padding des structures, les valeurs non initialisées ou le dépassement d’entier signé rendent le comportement difficile à prévoir et peuvent aussi mener à des failles de sécurité
- Un modèle mieux adapté au matériel moderne exploite de nombreux threads, de larges unités vectorielles et un modèle mémoire plus simple, mais la compatibilité avec le code C existant reste la principale contrainte
Pourquoi C a semblé « bas niveau »
- Un langage de bas niveau devrait permettre une correspondance simple entre les abstractions fournies par le matériel et la machine abstraite du langage
- C pouvait être considéré comme un langage de bas niveau sur le PDP-11
- Les programmes s’exécutaient séquentiellement
- La mémoire était traitée comme un espace plat
- Les opérateurs de pré-incrémentation et de post-incrémentation correspondaient bien aux modes d’adressage du PDP-11
- Alan Perlis définissait un langage de bas niveau comme un langage dans lequel « les programmes doivent prêter attention à ce qui n’a pas d’importance », mais cette définition seule n’explique pas entièrement la « proximité avec le matériel » attendue d’un langage de bas niveau
Les CPU modernes fonctionnent comme des émulateurs PDP-11 très rapides
- La cause profonde de Spectre et Meltdown ne tient pas seulement à la construction de processeurs rapides, mais aussi à une conception visant à exposer rapidement une machine abstraite de type PDP-11
- Le code C, jusqu’à C11 hors extensions non standard des fournisseurs, offrait en pratique une machine entièrement séquentielle, et même après C11 il conserve pour l’essentiel une machine abstraite séquentielle
- Les CPU modernes extraient du parallélisme au niveau des instructions (ILP) pour garder en permanence les unités d’exécution occupées
- Ils examinent les opérations voisines et émettent en parallèle celles qui sont indépendantes
- Cela permet aux programmeurs d’écrire du code majoritairement séquentiel, au prix d’une plus grande complexité et d’une consommation électrique accrue
- Les GPU peuvent atteindre de hautes performances sans cette logique, mais ils exigent des programmes explicitement parallèles
Spectre, Meltdown et le coût de l’exécution spéculative
- Les processeurs Intel modernes peuvent garder jusqu’à 180 instructions en vol en même temps
- Dans le code C, on peut considérer qu’il y a en moyenne une branche toutes les 7 instructions
- Pour remplir le pipeline sur un seul thread, il faut donc prédire la cible des 25 branches suivantes
- Une mauvaise prédiction produit un travail effectué puis jeté, tout en gaspillant de l’énergie
- Spectre et Meltdown ont pu exploiter en canal auxiliaire les effets de bord visibles de ce travail abandonné
- Sur les cœurs modernes hautes performances, le register rename engine est l’un des principaux consommateurs de surface de die et d’énergie
- Quand des instructions sont en cours d’exécution, il est difficile de l’éteindre ou de lui couper l’alimentation
- Les GPU n’ont pas ce type d’unité : leur parallélisme vient de multiples threads
Le modèle de mémoire plate de C ne correspond pas à la réalité des caches
- La mémoire plate, élément central de la machine abstraite de C, ne correspond plus au matériel réel depuis plus de 20 ans
- Les processeurs modernes disposent généralement de 3 niveaux de cache entre les registres et la mémoire principale
- Les caches sont, par définition, cachés au programmeur et invisibles pour C
- Pour obtenir du code rapide sur un processeur moderne, il faut utiliser efficacement les caches
- Le programmeur C doit connaître non seulement la machine abstraite, mais aussi les détails d’implémentation pour obtenir de bonnes performances
- Par exemple, deux valeurs alignées sur 64 octets peuvent se retrouver dans la même ligne de cache
La complexité du compilateur nécessaire pour rendre le code C rapide
- Un langage de bas niveau devrait pouvoir être transformé facilement en code rapide sans compilateur complexe, mais ce n’est pas le cas de C
- Clang et les parties associées de LLVM représentent environ 2 millions de lignes
- Si l’on ne compte que les passes d’analyse et de transformation nécessaires à l’exécution rapide de C, on approche déjà des 200 000 lignes hors commentaires et lignes vides
- En C, quand on traite de grands volumes de données, on écrit généralement des boucles qui parcourent séquentiellement chaque élément
- Pour s’exécuter de façon optimale sur un CPU moderne, le compilateur doit d’abord déterminer l’indépendance entre les itérations de boucle
- Le mot-clé
restrictpeut garantir qu’une écriture via un pointeur n’interfère pas avec une lecture via un autre pointeur
- Fortran est avantagé par rapport à C pour fournir ce type d’information, ce qui explique en partie pourquoi C n’a pas remplacé Fortran dans le calcul haute performance
Le conflit entre vectorisation et garanties de disposition mémoire en C
- Si les itérations de boucle sont indépendantes, le compilateur essaiera de vectoriser le résultat
- Les processeurs modernes peuvent obtenir un débit 4 à 8 fois supérieur avec du code vectoriel qu’avec du code scalaire
- Pour un langage de bas niveau destiné à ce type de processeurs, il serait naturel d’avoir des types vectoriels natifs de longueur arbitraire
- LLVM IR fournit ce modèle, car il est plus facile de découper de grandes opérations vectorielles en petites que l’inverse
- Les garanties de disposition mémoire de C entrent en conflit avec les optimisations
- On peut utiliser de manière interchangeable des structures ayant le même préfixe
- Les offsets des champs de structure sont exposés par le langage
- Il est difficile pour le compilateur de réordonner les champs ou d’insérer du padding pour améliorer la vectorisation
- La capacité à contrôler finement la disposition des structures de données peut être un avantage d’un langage de bas niveau, mais elle rend aussi C difficile à optimiser
Padding, SROA et loop unswitching
- C impose du padding en fin de structure pour garantir l’absence de padding dans les tableaux
- Les structures doivent pouvoir être comparées indépendamment du type via
memcmp, donc une copie de structure doit aussi préserver le padding- Certaines expériences ont montré qu’une part notable du temps total d’exécution de certaines charges était consacrée à copier du padding
- SROA est une optimisation qui tente de remplacer les structures et tableaux de taille fixe par des variables individuelles
- Elle permet de traiter indépendamment les accès et d’éliminer les opérations dont le résultat n’est pas observable
- Dans certains cas, elle supprime le padding, mais pas toujours
- Le loop unswitching est une optimisation qui sort une condition hors d’une boucle et crée une boucle sur chaque branche
- Cela entre en conflit avec l’idée, dans du code de bas niveau, que le programmeur sait quel code s’exécute et à quel moment
- Cela peut aussi poser problème avec les notions de unspecified value et d’undefined behavior en C
Valeurs non initialisées et comportement indéfini
- En C, lire une variable non initialisée produit une unspecified value, qui peut être différente à chaque lecture
- Cette règle permet des comportements comme la réutilisation paresseuse de pages mémoire
- L’implémentation de
mallocde FreeBSD signale au système d’exploitation les pages actuellement inutilisées - Le système d’exploitation peut utiliser la première écriture sur une page comme indication qu’elle redevient nécessaire
- L’implémentation de
- Si une unspecified value est utilisée dans le contrôle de flux, cela devient un undefined behavior
- Par exemple, utiliser une valeur non initialisée dans une condition
if
- Par exemple, utiliser une valeur non initialisée dans une condition
- Quand une boucle s’exécute zéro fois lors d’un loop unswitching, le code d’origine fait de tout le corps de boucle du code mort
- Après unswitching, on peut brancher sur une variable potentiellement non initialisée
- Du code mort se transforme alors en comportement indéfini
- On peut rendre le code C rapide, mais construire un compilateur suffisamment intelligent demande des milliers d’années-homme, et il faut parfois enfreindre certaines règles du langage
Pourquoi C est devenu difficile à comprendre
- Un langage de bas niveau devrait permettre au programmeur de comprendre facilement la correspondance entre la machine abstraite et la machine physique réelle
- Sur le PDP-11, les expressions C se mappaient facilement à une ou deux instructions, et les variables locales comme les types primitifs correspondaient simplement au matériel
- Par la suite, les implémentations de C sont devenues de plus en plus complexes pour préserver l’illusion d’un code rapide et d’une correspondance directe avec le matériel
- Une enquête menée en 2015 auprès de programmeurs C, d’auteurs de compilateurs et de membres de comités de normalisation a mis en évidence ce problème de compréhensibilité
- Après initialisation d’une structure à zéro puis affectation de certains champs, 36 % étaient certains que tous les bits de padding restaient à 0, et 29 % ont répondu ne pas savoir
- Le résultat réel peut varier selon le compilateur et le niveau d’optimisation
Provenance des pointeurs et failles de sécurité
- Le modèle BCPL reposait sur une idée relativement simple : chaque mot est soit une donnée, soit l’adresse d’une donnée
- Le modèle de C a été conçu pour pouvoir être implémenté sur des cibles variées, y compris des architectures segmentées ou des machines virtuelles à ramasse-miettes
- Pour éviter les problèmes sur de tels systèmes, le standard C limite les opérations valides sur les pointeurs
- C Defect Report 260 a introduit dans la définition des pointeurs la notion de pointer provenance
- Une implémentation peut suivre l’origine d’un motif de bits
- Elle peut distinguer des pointeurs de provenance différente même s’ils sont identiques au niveau binaire
- Le mot
provenancen’apparaît pas dans la spécification C11, si bien que les auteurs de compilateurs doivent eux-mêmes en fixer le sens- GCC et Clang diffèrent sur la question de savoir si la provenance est conservée lorsqu’un pointeur est converti en entier puis reconverti en pointeur
- Des failles de sécurité ont été observées dans du code avec dépassement d’entier signé et avec déréférencement de pointeur avant test de nullité
- Comme le déréférencement d’un pointeur null est un undefined behavior en C, le compilateur peut supposer qu’un pointeur déjà déréférencé ne peut pas être null
- Voir par exemple CVE-2009-1897
Imaginer des processeurs qui ne seraient pas conçus pour C
- Les correctifs proposés pour Spectre et Meltdown imposent une pénalité de performance significative et annulent une part importante des avancées microarchitecturales des dix dernières années
- Plutôt que de chercher à exécuter rapidement du code C, il est peut-être temps de repenser le modèle de programmation pour des processeurs rapides
- Des puces fortement multithreadées comme les Sun/Oracle UltraSPARC Tx n’ont pas besoin d’autant de cache pour occuper leurs unités d’exécution
- Avec suffisamment de parallélisme de haut niveau, on peut suspendre les threads en attente mémoire et remplir les unités d’exécution avec les instructions d’autres threads
- Le problème est que les programmes C ont tendance à avoir peu de threads réellement occupés
- ARM SVE (Scalar Vector Extensions) est un exemple d’interface plus adaptée entre programme et matériel
- Les unités vectorielles classiques exposent des opérations sur des vecteurs de taille fixe et attendent du compilateur qu’il adapte les algorithmes à cette taille
- SVE permet au programmeur de décrire le degré de parallélisme disponible, puis laisse le matériel le mapper au nombre d’unités d’exécution
- En C, l’autovectorizer doit inférer le parallélisme à partir de la structure des boucles, ce qui est complexe, tandis que dans une opération
mapde style fonctionnel, la longueur du tableau à traiter représente directement le parallélisme disponible, ce qui simplifie la génération de code
Un modèle mémoire plus simple et la programmation parallèle
- Dans les CPU modernes, le cache coherency protocol est l’un des éléments les plus difficiles à rendre à la fois rapide et correct
- Une grande partie de cette complexité vient du support de langages qui supposent que les données peuvent être partagées et mutables
- Dans une machine abstraite de style Erlang, tous les objets sont soit locaux à un thread, soit immuables
- Erlang a un modèle plus simple où chaque thread n’a qu’un seul objet mutable
- Le protocole de cohérence de cache d’un tel système peut se réduire à deux cas : mutable ou partagé
- Les objets immuables peuvent simplifier les caches et rendre de nombreuses opérations moins coûteuses
- Le Project Maxwell de Sun Labs a observé que l’ensemble des objets présents dans le cache et celui des objets alloués dans la young generation se recouvrent presque complètement
- Si un objet meurt avant d’être expulsé du cache, il n’a pas besoin d’être réécrit en mémoire principale, ce qui économise de l’énergie
- Avec des objets immuables sur le tas et une pile mutable, le garbage collector peut devenir une machine à états simple, facile à implémenter en matériel
- Un processeur conçu uniquement pour la vitesse aurait probablement de nombreux threads, de larges unités vectorielles et un modèle mémoire plus simple
- Exécuter du code C sur un tel système pourrait poser problème
- Comme il existe énormément de code C legacy dans le monde, une réussite commerciale serait difficile
- L’idée reçue selon laquelle la programmation parallèle est difficile est plus exacte pour la programmation parallèle dans des langages dotés d’une machine abstraite comme celle de C
- Alan Kay a enseigné des langages à modèle acteur à des enfants, qui ont écrit des programmes fonctionnels avec plus de 200 threads
- Les programmeurs Erlang écrivent couramment des programmes comportant des milliers de composants parallèles
- À l’ère des CPU multicœurs et des GPU many-core omniprésents, C se mappe mal au matériel moderne
1 commentaires
Avis sur Hacker News
Si C est bas niveau, c’est au moins à cause de la gestion manuelle de la mémoire
Sur le matériel moderne en particulier, la gestion de la mémoire est au cœur de la programmation. Si Rust met en avant la sûreté mémoire sans garbage collector, c’est aussi parce que la raison d’être de Rust est, au fond, très proche de la gestion de la mémoire. Si C est rapide, c’est grâce à la mémoire ; si C n’est pas sûr, c’est aussi, dans l’ensemble, à cause de la mémoire. L’une des grandes raisons pour lesquelles le calcul parallèle est difficile est également l’accès concurrent à la mémoire. La programmation fonctionnelle est souvent entourée de concepts mathématiques, mais une bonne partie consiste à faire comme si les objets étaient immuables, tandis qu’en interne le compilateur gère de la mémoire mutable
En C, si l’on utilise un allocateur, tous ses appels sont explicites. En ancien C++,
new/deleteet les pointeurs bruts appellent aussi explicitement un allocateur, mais beaucoup de choses se produisent automatiquement dans les destructeurs. Les pointeurs intelligents du C++ moderne ressemblent fondamentalement aux langages à garbage collection, dans la mesure où l’allocation comme la libération se font automatiquementOn ne peut pas indiquer au processeur à quel niveau de cache placer quelles données, quoi envoyer en mémoire virtuelle, etc. C’est plus bas niveau que Python, mais il est difficile de parler de gestion mémoire bas niveau comme à l’époque du C sur PDP-11
Pour les systèmes de type microcontrôleur ou dépourvus de MMU, l’histoire est différente, mais c’est encore un autre sujet
Même moi, développeur Rust, je travaille dans l’illusion que les pointeurs sont des objets physiques réels, comme des adresses mémoire. Rust, et dans une certaine mesure C++, mettent en avant des abstractions de gestion comme les références et l’emprunt, mais le concept central reste le même
En réalité, le noyau du système d’exploitation interpose une énorme couche entre la mémoire physique et le programme, et les « adresses » comme les « pointeurs » ressemblent plutôt à des handles sur lesquels l’OS et la MMU effectuent toutes sortes de traitements
Même les « pointeurs bruts » ne sont en fait pas bruts. Ce sont des handles vers des offsets dans des pages, et les pages réelles peuvent être dispersées un peu partout. Si l’on quitte complètement libc et le modèle C pour aller vers un monde de références pures interagissant directement avec les pages du sous-système VM, une sorte de monde de « handles d’objets », on pourrait même se rapprocher davantage du fonctionnement réel du sous-système sous-jacent
mallocetfreesont des fonctions de bibliothèqueLe matériel ne dispose pas d’allocation octet par octet de cette manière ; c’est donc non seulement une abstraction au-dessus du matériel, mais aussi une abstraction de la manière dont le système d’exploitation alloue la mémoire
En C, on ne peut pas non plus accéder directement à la pile. Les frames de pile sont abstraites, et ce que l’on peut utiliser se limite à quelque chose comme
longjmpSi l’on tient compte du comportement indéfini et des règles strictes d’aliasing, on n’a pas tant que ça le droit d’aller trifouiller la mémoire comme on veut
En tant que programmeur C et auteur de compilateurs, pour quelqu’un qui comprend C et l’utilise de façon professionnelle, C est clairement un langage de bas niveau.
Si vous cherchez un langage de bas niveau, C et ses proches cousins sont les meilleurs choix.
Si vous débutez en C et voulez savoir l’utiliser comme un expert, mieux vaut ignorer cet article. Il risque surtout de vous embrouiller et de diminuer votre capacité à utiliser C efficacement.
Il offre un accès de bas niveau à une machine que les machines réelles doivent émuler avec pas mal d’efforts. Les dispositifs branlants et rustines ajoutés au fil des ans pour accéder à la machine réelle sont des éléments relativement étrangers au sein de C.
Cela dit, je suis d’accord pour dire que le titre est rhétoriquement brutal. Être un mauvais langage de bas niveau ne fait pas de C un langage de haut niveau. WASM aussi serait « mauvais » si l’on prétendait qu’il correspond directement au matériel moderne, mais cela n’en fait pas un langage de haut niveau.
Le fait que C soit une mauvaise correspondance n’est pas en soi frustrant. C’est un langage des années 1970, donc cela se comprend, et il reste clairement utile dans beaucoup de cas. Ce qui est plus frustrant, c’est que C continue d’influencer fortement la conception des langages, et de colorer fortement la manière dont les concepteurs de langages voient le matériel. Résultat : la conception de langages modernes se contente trop souvent de remixer des morceaux de C au lieu de créer des langages qui s’accordent bien avec le matériel.
Si vous pensez que le code que vous écrivez aura une relation un-pour-un avec l’assembleur, vous allez avoir des problèmes. Pour voir plus en détail comment ce genre de chose peut vous piéger, regardez https://youtu.be/w3_e9vZj7D8.
Son idée centrale n’est sans doute pas que « C n’est pas un bon langage pour la programmation système ». Il est difficile d’écrire en Haskell quelque chose d’équivalent à
volatile int *dma_register = SCATTER_GATHER_BASE;.Son propos est que la volonté de faire s’exécuter rapidement C et d’autres langages qui « modélisent une machine de von Neumann » a rendu les compilateurs très complexes, et qu’il laisse entendre que « si c’est bas niveau, le compilateur devrait être simple ». Les processeurs conçus pour exécuter rapidement ce type de code sont eux aussi très complexes, et cette complexité a un coût.
À bien des égards, c’est un texte qui appelle à un changement de modèle de programmation, et il cite les GPU comme exemple de ce qui devient possible quand un « nouveau modèle de programmation » et le « silicium qui le prend en charge » sont conçus ensemble.
Son sens d’origine est plus proche de celui employé dans l’article. Un langage de bas niveau n’est pas portable et est lié au matériel sur lequel il s’exécute, tandis qu’un langage de haut niveau peut cibler plusieurs plateformes. Selon cette définition, C est clairement un langage de haut niveau.
Ce qui me gêne, ce n’est pas tant que l’auteur joue sur les mots, mais qu’il s’accroche à une terminologie vieillie qui brouille plutôt la compréhension. La classification par « générations » est généralement plus explicative.
La 1re génération est le langage machine, la 2e l’assembleur, la 3e les langages généralistes, la 4e les langages spécifiques à un domaine d’application.
La frontière entre 3e et 4e génération est parfois floue, et dans les années 80-90 on parlait aussi d’une 5e génération qui ne s’est finalement pas imposée. Malgré tout, SQL, HyperCard et Mathematica me semblent être des exemples assez nets de langages de 4e génération.
L’intérêt de cette approche est qu’elle classe les langages selon des différences relativement claires dans leurs usages. Ensuite, on peut employer « haut niveau/bas niveau » comme des termes relatifs. Plus un langage est de haut niveau, plus il tend à abstraire les détails de ce que l’ordinateur fait réellement. Cela préserve l’idée que les langages de générations plus élevées sont généralement de plus haut niveau, et la seule chose que l’on perd, ce sont les débats absurdes autour de frontières totalement arbitraires et, franchement, inutiles.
Avec cette approche, on peut voir .NET IL, WebAssembly et le bytecode Java comme des langages de 2e génération de très haut niveau, ce qui est amusant. Et Forth est un langage de 3e génération. Chuck peut venir en découdre s’il veut.
Il ne s’agit pas tant de savoir comment utiliser un marteau, mais plutôt de se demander si notre manière d’utiliser le marteau partout — autrement dit, la conception de C — ne nous limite pas.
Je ne suis pas d’accord avec l’affirmation de l’auteur selon laquelle le jeu d’instructions du CPU devrait exposer davantage l’implémentation du CPU.
Cela a déjà été tenté par le passé, et a échoué à long terme. On peut citer les slots de délai de branchement de certains processeurs RISC conçus à la fin des années 80 et au début des années 90, comme MIPS et SuperH. Pour expliquer le concept à ceux qui ne le connaissent pas : l’instruction qui suit une instruction de branchement est exécutée, que le branchement soit pris ou non.
À court terme, cela permettait de rendre le processeur plus simple et moins cher en confiant au programmeur la tâche d’éviter un arrêt du pipeline après un branchement. Mais avec le temps, les conceptions de processeurs et les pipelines sont devenus plus complexes, et une seule instruction ne suffisait plus à masquer le délai de branchement. Au final, c’est devenu un héritage que les processeurs futurs devaient gérer pour des raisons de compatibilité, ce qui a rendu la prédiction de branchement et la logique de pipeline plus complexes.
Exposer les mauvais détails est évidemment une mauvaise chose. Il dit plutôt qu’il existe des limites importantes au modèle de C dans le monde des CPU modernes.
J’ai entendu une présentation où des développeurs utilisaient ce type de sous-système sur un processeur. Sans l’utiliser, ils passaient 95 % de leur fenêtre de temps uniquement à copier des données ; en demandant les données à l’avance via ce moteur, ils ne consacraient plus que 10 % de la fenêtre de temps à l’acquisition des données, et terminaient le travail voulu en environ 50 % de la fenêtre totale, laissant beaucoup de temps pour des fonctionnalités et améliorations supplémentaires.
Si x86 avait eu une telle fonction, je l’aurais utilisée pendant mon doctorat pour demander à l’avance les données de matrices auxquelles j’allais accéder. Le motif que j’utilise n’est pas linéaire, mais il est bien défini. Aujourd’hui, pour accélérer davantage ce code, il faudrait réorganiser les matrices pour plaire au préfetcher et refactorer toute la base de code de haut en bas.
On peut toujours concevoir quelque chose de médiocre si on le veut, mais je me demande combien il existe d’exemples historiques permettant de généraliser.
Je vois le passage du bas niveau au haut niveau non pas comme une dichotomie, mais comme un spectre
On peut dire que C se situe dans le tiers inférieur des langages, et expose à de nombreux éléments primitifs de la machine, comme la gestion de la mémoire et des threads. Même s’il ne descend pas aussi bas que l’assembleur, il est plus bas niveau que Java ou Go, et clairement très éloigné de Python ou JavaScript.
De plus, C est assez mal adapté aux plateformes utilisant une mémoire segmentée ou des adresses non plates. On voit des signes que ce genre de choses pourrait redevenir à la mode, et la diffusion très large de C constitue vraiment un énorme obstacle à cela.
Mon modèle mental a donc toujours été : « C est le niveau le plus bas auquel on peut descendre avant de donner directement des instructions au processeur ».
« C ne se comporte pas comme un langage typiquement “haut niveau”. Il fournit en effet plusieurs fonctionnalités plus souvent associées à des langages “bas niveau” comme l’assembleur. Cela inclut la capacité d’écrire et de lire des données à des adresses mémoire spécifiques, d’effectuer des opérations sur le contenu d’emplacements mémoire, ainsi que des instructions pour incrémenter et décrémenter des variables entières … C offre donc au programmeur la flexibilité et l’efficacité du travail à bas niveau, tout en fournissant aussi les avantages des opérations de haut niveau typiques des langages informatiques actuels, comme des structures de données plus évoluées et le contrôle du flux du programme. Pour cette raison, C est parfois décrit comme un “langage bas niveau de haut niveau” ou un “langage haut niveau de bas niveau”. » - https://archive.org/details/computerprogramm0000ford/page/13...
La phrase de fin de l’article, « il existe un mythe courant en développement logiciel selon lequel la programmation parallèle est difficile », prête à confusion
L’auteur donne certes des situations concrètes où elle n’est pas difficile, mais si l’on pose la question de façon générale, la programmation parallèle est difficile, et ce n’est pas un mythe courant
La programmation parallèle est-elle difficile ? Si on pose la question sans conditions plus détaillées, oui. Il est beaucoup plus difficile de conceptualiser des instructions de code s’exécutant simultanément que s’exécutant une par une de manière séquentielle.
(map inc [0 1 2 3]), la difficulté à conceptualiser l’exécution de la fonctionincsur chaque élément est-elle vraiment différente selon qu’elle est séquentielle ou parallèle ?Je pense que la difficulté de la programmation parallèle tient moins à quelque chose d’inné qu’à deux aspects
Premièrement, les langages prennent généralement l’exécution séquentielle comme modèle par défaut, donc pour faire de l’asynchrone il faut introduire des primitives supplémentaires pour le programmeur
Deuxièmement, il faut savoir quand utiliser efficacement la programmation parallèle
Si l’on a une liste ou un flux d’éléments indépendants qui ne nécessitent que des calculs indépendants, la programmation parallèle est intuitive
Là où les gens bloquent, c’est quand ils essaient de l’imposer là où l’asynchrone n’est pas nécessaire — c’est-à-dire là où les performances sont identiques ou pires qu’en séquentiel — ou quand les calculs sont en réalité interdépendants et que l’ajout d’asynchronisme casse le comportement.
Quand on dit « sans détails supplémentaires ni spécificité », on prend en réalité comme cadre par défaut la vision du monde C/famille C
L’idée de l’auteur est que la programmation séquentielle n’est qu’un type de programmation simple, pas le seul, et qu’elle ne s’adapte pas facilement au matériel moderne.
Le fait qu’Erlang existe et que des gens l’utilisent avec succès ne signifie pas qu’une chose plus difficile ne soit pas difficile.
Implémenter des algorithmes parallèles avec une infrastructure de programmation concurrente comme des processus ou des threads est également difficile. Mais la programmation parallèle, c’est-à-dire faire en sorte que de nombreux éléments de traitement accomplissent ensemble la même tâche, est beaucoup plus facile avec les bonnes abstractions.
Certains cas d’usage, comme la multiplication de matrices, font toutefois exception.
L’article a raison de dire qu’un ordinateur n’est pas un PDP-11 rapide, mais il a tort de dire que cela a un rapport avec C
Par exemple, on y lit : « un autre pilier du modèle mémoire de la machine abstraite C : la mémoire plate. Cela fait plus de vingt ans que ce n’est plus vrai »
Cela n’a rien à voir avec C. Le matériel impose cette abstraction. Et c’est une bonne chose. Sinon, un programme s’arrêterait lorsqu’on le déplacerait vers une machine dotée d’un autre cache.
Par exemple, des hiérarchies mémoire qui prétendent être de la RAM plate, des CPU bien plus grands que ce que suggère le jeu d’instructions, avec exécution dans le désordre et spéculative, ainsi que des compilateurs optimisants qui séparent davantage le programme écrit de l’exécution réelle
IBM travaillait sur ces choses dans les années 1970, bien avant l’essor de C. Critiquer ce modèle et chercher des alternatives est légitime, mais il n’est pas juste d’en faire porter la faute à C.
Cet article date maintenant de cinq ans, et l’idée de départ selon laquelle les ordinateurs ne ressemblent plus beaucoup, structurellement, au PDP-11 est devenue encore plus juste ; en revanche, la conclusion qui invite à « imaginer un processeur non-C » paraît moins forte
Nous observons une séparation nette entre le code linéaire et le code massivement parallèle, et c’était déjà le cas en 2018. L’exemple le plus évident est l’essor de Python dans le machine learning et le calcul scientifique. Quand la performance n’est pas la priorité absolue, il reste très pratique d’écrire dans un style monothread avec un modèle de mémoire plat
Quand la performance devient importante, il est pertinent de passer à des langages mieux adaptés à la programmation parallèle. C’est le cas des langages de graphes de calcul de choses comme Pytorch, d’autres ensembles de primitives au-dessus de CUDA, ou de langages plus expérimentaux comme Futhark. Le code critique pour la performance a toujours eu recours à des langages spécifiques à un domaine, et ceux-ci semblent devenir plus courants, pas moins. Le matériel est lui aussi conçu en conséquence. On peut citer la combinaison CPU+GPU courante dans les PC de bureau, les extensions vectorielles x86 avec des primitives qui constituent pratiquement leur propre DSL, ou encore des puces comme le M1, qui accolent le GPU au CPU afin que tous deux disposent d’un accès rapide à la même mémoire système
Autrement dit, ce qui est vraiment dépassé n’est peut-être pas C, mais l’idée d’un langage généraliste qui conviendrait aussi bien à tous les types de tâches
Si, en raison de la sophistication des CPU modernes, C n’est plus un langage « bas niveau », alors le même raisonnement s’applique aussi à l’assembleur
Car des mécanismes comme l’exécution dans le désordre et le renommage de registres s’appliquent aussi à l’assembleur
La sophistication croissante des compilateurs ces dernières décennies renforce également cet argument. L’assembleur généré par un compilateur C — autrement dit le code objet — peut lui aussi différer des attentes à cause du déplacement de calculs hors des boucles, de l’élimination de sous-expressions communes, etc.
Je pense néanmoins que qualifier C de langage « bas niveau » reste une étiquette utile. Sinon, il faudrait abandonner complètement cette appellation
C’est bien une abstraction au-dessus de l’ordinateur réel, mais bien moindre que ce que C empile sur son modèle de machine virtuelle. L’assembleur actuel se situe à peu près au niveau où se trouvait C au moment de sa création. Le C actuel est trop haut niveau pour offrir des fonctionnalités qu’on ne pourrait pas obtenir avec des langages meilleurs et plus modernes
Cela dit, je suis d’accord pour dire qu’aujourd’hui les appellations « bas niveau » et « haut niveau » ne sont pas très utiles
Le texte semble développer deux lignes d’argumentation difficiles à concilier
La première affirme que C n’est pas un langage bas niveau, en prenant comme exemples le padding des structures et le fait que le dépassement d’entier signé soit un comportement indéfini. Cette partie se comprend, et paraît constructive dans la mesure où elle propose des fonctionnalités de langage pour un hypothétique « vrai langage bas niveau »
La seconde affirme qu’à cause de la domination de C, les concepteurs de CPU ont dû se donner du mal pour créer quelque chose capable d’exécuter C naturellement. Les exemples incluent le renommage de registres, la mémoire plate et le caching. Cet argument se comprend aussi, mais je ne vois pas bien comment il se rattache au premier argument ni au titre de l’article. Pris littéralement, il semble signifier qu’il est impossible de créer un langage bas niveau sur le matériel moderne, et que même le langage machine serait « haut niveau ». On en conclurait alors qu’il faut d’abord créer une nouvelle génération de matériel exposant beaucoup plus de complexité dans l’architecture du jeu d’instructions, puis seulement ensuite concevoir un langage bas niveau capable d’en tirer parti
Les deux arguments ont de la valeur, mais les réunir dans un seul article intitulé « C n’est pas un langage bas niveau » rend l’ensemble un peu instable. Le premier argument correspond bien à ce titre ; le second aurait sans doute mieux trouvé sa place dans un article de suivi intitulé « Le langage machine non plus n’est pas un langage bas niveau »
Mais les temps de compilation étaient longs, et j’ai entendu dire que les compilateurs n’ont finalement jamais atteint le niveau d’optimisation attendu. Le fait qu’il ne soit pas compatible avec x86 n’a pas non plus aidé son adoption
Cela me fait penser au VLIW. D’après l’article Wikipédia sur l’Itanium :
« Un mot d’instruction VLIW peut contenir plusieurs instructions indépendantes pouvant être exécutées en parallèle sans évaluation de leur indépendance. Le compilateur doit tenter de trouver des combinaisons valides d’instructions pouvant être exécutées simultanément et, de fait, réalise l’ordonnancement des instructions que les processeurs superscalaires classiques effectuent à l’exécution, en matériel. »
Si le CPU expose au niveau de son interface le parallélisme d’un flux unique, on pourrait soit le traiter à la compilation, soit le décider directement avec de l’assembleur inline.
Je me demande si cela ne s’est pas imposé à cause de la dynamique commerciale de l’industrie, ou s’il existe de vraies raisons techniques pour lesquelles cette stratégie n’est pas bonne.
Premièrement, les compilateurs n’étaient pas très bons pour ce type d’ordonnancement d’instructions, et quand ils se sont améliorés plus tard, l’Itanium avait déjà coulé. Deuxièmement, les jeux d’instructions existants, c’est-à-dire x86, se sont mis à gérer cela plutôt bien à l’exécution, en matériel, et obtenaient en fait des résultats légèrement meilleurs que l’ordonnancement statique. À l’exécution, on dispose de données de profilage.
Linus a laissé un bon coup de gueule un peu lié à ce sujet en [0], je crois. « Pendant que les gens du RISC essayaient d’optimiser les compilateurs pour produire des boucles utilisant efficacement les 32 registres, les implémenteurs x86 ont plutôt fait en sorte que la puce tourne vite sous diverses charges, avec énormément de matériel de renommage de registres. On voit aussi arriver le renommage de mémoire. »
[0] https://yarchive.net/comp/linux/x86.html
Le VLIW fonctionne vraiment bien dans certaines niches. Il est plus difficile à programmer, à la main comme via un compilateur, qu’une instruction unique exécutée dans l’ordre, mais il simplifie l’ordonnancement côté matériel. Il fonctionne mieux lorsque les instructions regroupées ont des latences similaires.
Aujourd’hui, le casse-tête central de conception vient du fait que les accès mémoire consomment beaucoup plus de cycles que l’arithmétique. Regrouper une opération arithmétique de quelques cycles avec un chargement mémoire de plusieurs centaines de cycles n’a pas grand intérêt. Le VLIW fonctionne donc bien quand on sait que les accès mémoire seront rapides, en gros quand on sait qu’ils tiendront dans le cache L1 ou l’équivalent. Je pense que c’est l’une des raisons pour lesquelles il convient bien aux systèmes de type DSP.
Les pipelines exposés sont aussi une caractéristique intéressante de certains de ces systèmes. Si une instruction d’un paquet VLIW écrit dans un registre, les instructions suivantes qui lisent ce même registre voient l’ancienne valeur pendant les N cycles suivants, puis seulement l’écriture devient visible. C’est vraiment déroutant à programmer à la main, mais un compilateur peut gérer ce type d’ordonnancement.
Jusqu’à récemment, le DSP et le HPC ne représentaient qu’une très petite part du marché ; les architectures capables d’ordonnancement dynamique ont donc reçu davantage d’investissements et ont fini par dominer même ces marchés.
Sur les GPU, bien sûr, la situation a changé, et les GPU se sont effectivement davantage appuyés sur l’ordonnancement statique. Mais à mesure que les GPU s’étendent à des charges plus variées, ils gagnent eux aussi de plus en plus d’éléments dynamiques.
https://news.ycombinator.com/context?id=37900987
L’Itanium a été la principale tentative de lancer cela comme CPU. Aujourd’hui, AMD64 et ARM dominent, mais on pourrait peut-être en revoir à l’avenir.