À quoi ressemble le langage d'assemblage multicœur?

243

Il était une fois, pour écrire un assembleur x86, par exemple, vous auriez des instructions indiquant "charger le registre EDX avec la valeur 5", "incrémenter le registre EDX", etc.

Avec les processeurs modernes qui ont 4 cœurs (ou même plus), au niveau du code machine, semble-t-il simplement qu'il y a 4 processeurs distincts (c'est-à-dire qu'il n'y a que 4 registres "EDX" distincts)? Si tel est le cas, lorsque vous dites "incrémenter le registre EDX", qu'est-ce qui détermine le registre EDX du processeur incrémenté? Existe-t-il maintenant un concept de «contexte CPU» ou de «thread» dans l'assembleur x86?

Comment fonctionne la communication / synchronisation entre les cœurs?

Si vous écriviez un système d'exploitation, quel mécanisme est exposé via le matériel pour vous permettre de planifier l'exécution sur différents cœurs? S'agit-il d'instructions privilégiées spéciales?

Si vous écriviez une machine virtuelle de compilation / bytecode d'optimisation pour un processeur multicœur, que devriez-vous savoir spécifiquement sur, disons, x86 pour lui faire générer du code qui s'exécute efficacement sur tous les cœurs?

Quelles modifications ont été apportées au code machine x86 pour prendre en charge la fonctionnalité multicœur?

Paul Hollingsworth
la source
2
Il y a une question similaire (mais pas identique) ici: stackoverflow.com/questions/714905/…
Nathan Fellman

Réponses:

153

Ce n'est pas une réponse directe à la question, mais c'est une réponse à une question qui apparaît dans les commentaires. Essentiellement, la question est de savoir quel support le matériel donne au fonctionnement multi-thread.

Nicholas Flynt avait raison , du moins en ce qui concerne x86. Dans un environnement multithread (Hyper-threading, multicœur ou multiprocesseur), le thread Bootstrap (généralement le thread 0 dans le noyau 0 dans le processeur 0) démarre la récupération du code à partir de l'adresse 0xfffffff0. Tous les autres threads démarrent dans un état de veille spécial appelé Wait-for-SIPI . Dans le cadre de son initialisation, le thread principal envoie une interruption inter-processeur (IPI) spéciale sur l'APIC appelée SIPI (Startup IPI) à chaque thread se trouvant dans WFS. Le SIPI contient l'adresse à partir de laquelle ce thread doit commencer à récupérer le code.

Ce mécanisme permet à chaque thread d'exécuter du code à partir d'une adresse différente. Tout ce qui est nécessaire est un support logiciel pour chaque thread pour configurer ses propres tables et files d'attente de messagerie. Le système d' exploitation utilise les faire la programmation multithread réelle.

En ce qui concerne l'assemblage réel, comme l'écrivait Nicholas, il n'y a pas de différence entre les assemblages pour une application à thread unique ou à threads multiples. Chaque thread logique a son propre ensemble de registres, écrivant ainsi:

mov edx, 0

mettra à jour uniquement EDXpour le thread en cours d'exécution . Il n'y a aucun moyen de modifier EDXsur un autre processeur à l'aide d'une seule instruction d'assemblage. Vous avez besoin d'une sorte d'appel système pour demander au système d'exploitation de dire à un autre thread d'exécuter du code qui mettra à jour le sien EDX.

Nathan Fellman
la source
2
Merci d'avoir comblé la lacune dans la réponse de Nicholas. J'ai marqué la vôtre comme réponse acceptée maintenant ... donne les détails spécifiques qui m'intéressaient ... bien qu'il serait préférable qu'il y ait une seule réponse qui aurait vos informations et celles de Nicolas toutes combinées.
Paul Hollingsworth
3
Cela ne répond pas à la question de l'origine des threads. Les cœurs et les processeurs sont une chose matérielle, mais en quelque sorte les threads doivent être créés dans le logiciel. Comment le thread principal sait-il où envoyer le SIPI? Ou bien le SIPI lui-même crée-t-il un nouveau fil?
rich remer
7
@richremer: Il semble que vous confondiez les threads HW et SW. Le thread HW existe toujours. Parfois, il dort. Le SIPI lui-même réveille le thread HW et lui permet d'exécuter SW. Il appartient au système d'exploitation et au BIOS de décider quels threads HW s'exécutent, et quels processus et threads SW s'exécutent sur chaque thread HW.
Nathan Fellman
2
Beaucoup d'informations bonnes et concises ici, mais c'est un gros sujet - les questions peuvent donc persister. Il existe quelques exemples de noyaux "bare bones" complets à l'état sauvage qui démarrent à partir de lecteurs USB ou de disquettes - voici une version x86_32 écrite en assembleur en utilisant les anciens descripteurs TSS qui peuvent réellement exécuter du code C multithread ( github. com / duanev / oz-x86-32-asm-003 ) mais il n'y a pas de support de bibliothèque standard. Un peu plus que ce que vous aviez demandé, mais cela peut peut-être répondre à certaines de ces questions persistantes.
duanev
87

Exemple de baremetal exécutable minimal Intel x86

Exemple de métal nu exécutable avec toutes les plaques chauffantes requises . Toutes les parties principales sont couvertes ci-dessous.

Testé sur Ubuntu 15.10 QEMU 2.3.0 et sur le véritable invité matériel Lenovo ThinkPad T400 .

Le Guide de programmation du système Intel Manual Volume 3 - 325384-056F septembre 2015 couvre SMP dans les chapitres 8, 9 et 10.

Tableau 8-1. "Broadcast INIT-SIPI-SIPI Sequence and Choice of Timeouts" contient un exemple qui fonctionne simplement:

MOV ESI, ICR_LOW    ; Load address of ICR low dword into ESI.
MOV EAX, 000C4500H  ; Load ICR encoding for broadcast INIT IPI
                    ; to all APs into EAX.
MOV [ESI], EAX      ; Broadcast INIT IPI to all APs
; 10-millisecond delay loop.
MOV EAX, 000C46XXH  ; Load ICR encoding for broadcast SIPI IP
                    ; to all APs into EAX, where xx is the vector computed in step 10.
MOV [ESI], EAX      ; Broadcast SIPI IPI to all APs
; 200-microsecond delay loop
MOV [ESI], EAX      ; Broadcast second SIPI IPI to all APs
                    ; Waits for the timer interrupt until the timer expires

Sur ce code:

  1. La plupart des systèmes d'exploitation rendront la plupart de ces opérations impossibles depuis l'anneau 3 (programmes utilisateur).

    Vous devez donc écrire votre propre noyau pour jouer librement avec lui: un programme Linux utilisateur ne fonctionnera pas.

  2. Au début, un seul processeur s'exécute, appelé le processeur d'amorçage (BSP).

    Il doit réveiller les autres (appelés Processeurs d'application (AP)) par le biais d'interruptions spéciales appelées Interruptions de processeur (IPI) .

    Ces interruptions peuvent être effectuées en programmant le contrôleur d'interruption programmable avancé (APIC) via le registre de commande d'interruption (ICR)

    Le format de l'ICR est documenté à: 10.6 "ÉMISSION D'INTERRUPTIONS D'INTERPROCESSEUR"

    L'IPI se produit dès que nous écrivons à l'ICR.

  3. ICR_LOW est défini à 8.4.4 "Exemple d'initialisation MP" comme:

    ICR_LOW EQU 0FEE00300H
    

    La valeur magique 0FEE00300est l'adresse mémoire de l'ICR, comme indiqué dans le tableau 10-1 "Carte d'adresse du registre APIC local"

  4. La méthode la plus simple possible est utilisée dans l'exemple: elle configure l'ICR pour envoyer des IPI de diffusion qui sont délivrés à tous les autres processeurs à l'exception du processeur actuel.

    Mais il est également possible, et recommandé par certains , d'obtenir des informations sur les processeurs via des structures de données spéciales configurées par le BIOS comme les tables ACPI ou la table de configuration MP d'Intel et de ne réveiller que celles dont vous avez besoin une par une.

  5. XXen 000C46XXHcode l'adresse de la première instruction que le processeur exécutera comme:

    CS = XX * 0x100
    IP = 0
    

    N'oubliez pas que CS multiplie les adresses par0x10 , donc l'adresse mémoire réelle de la première instruction est:

    XX * 0x1000
    

    Donc, si par exemple XX == 1, le processeur démarre à 0x1000.

    Nous devons ensuite nous assurer qu'il y a du code en mode réel 16 bits à exécuter à cet emplacement mémoire, par exemple avec:

    cld
    mov $init_len, %ecx
    mov $init, %esi
    mov 0x1000, %edi
    rep movsb
    
    .code16
    init:
        xor %ax, %ax
        mov %ax, %ds
        /* Do stuff. */
        hlt
    .equ init_len, . - init
    

    L'utilisation d'un script de l'éditeur de liens est une autre possibilité.

  6. Les boucles de retard sont une partie gênante pour se mettre au travail: il n'y a pas de moyen super simple de faire de telles nuits avec précision.

    Les méthodes possibles incluent:

    • PIT (utilisé dans mon exemple)
    • HPET
    • calibrer le temps d'une boucle occupée avec ce qui précède, et l'utiliser à la place

    Connexe: Comment afficher un nombre à l'écran et dormir pendant une seconde avec l'assemblage DOS x86?

  7. Je pense que le processeur initial doit être en mode protégé pour que cela fonctionne lorsque nous écrivons à l'adresse 0FEE00300Hqui est trop élevée pour 16 bits.

  8. Pour communiquer entre les processeurs, nous pouvons utiliser un verrou tournant sur le processus principal et modifier le verrou à partir du deuxième cœur.

    Nous devons nous assurer que la réécriture de la mémoire est effectuée, par exemple via wbinvd.

État partagé entre les processeurs

8.7.1 "État des processeurs logiques" dit:

Les fonctionnalités suivantes font partie de l'état architectural des processeurs logiques des processeurs Intel 64 ou IA-32 prenant en charge la technologie Intel Hyper-Threading. Les fonctionnalités peuvent être subdivisées en trois groupes:

  • Dupliqué pour chaque processeur logique
  • Partagé par des processeurs logiques dans un processeur physique
  • Partagé ou dupliqué, selon l'implémentation

Les fonctionnalités suivantes sont dupliquées pour chaque processeur logique:

  • Registres à usage général (EAX, EBX, ECX, EDX, ESI, EDI, ESP et EBP)
  • Registres de segment (CS, DS, SS, ES, FS et GS)
  • Registres EFLAGS et EIP. Notez que les registres CS et EIP / RIP pour chaque processeur logique pointent vers le flux d'instructions pour le thread exécuté par le processeur logique.
  • Registres FPU x87 (ST0 à ST7, mot d'état, mot de contrôle, mot d'étiquette, pointeur d'opérande de données et pointeur d'instruction)
  • Registres MMX (MM0 à MM7)
  • Registres XMM (XMM0 à XMM7) et registre MXCSR
  • Registres de contrôle et registres de pointeur de table système (GDTR, LDTR, IDTR, registre de tâches)
  • Registres de débogage (DR0, DR1, DR2, DR3, DR6, DR7) et les MSR de contrôle de débogage
  • MSR d'état global de vérification de machine (IA32_MCG_STATUS) et de capacité de vérification de machine (IA32_MCG_CAP)
  • Modulation d'horloge thermique et contrôle de gestion de l'alimentation ACPI MSR
  • Compteur d'horodatage MSR
  • La plupart des autres registres MSR, y compris la table des attributs de page (PAT). Voir les exceptions ci-dessous.
  • Registres APIC locaux.
  • Registres généraux supplémentaires (R8-R15), registres XMM (XMM8-XMM15), registre de contrôle, IA32_EFER sur les processeurs Intel 64.

Les fonctionnalités suivantes sont partagées par les processeurs logiques:

  • Registres de plage de types de mémoire (MTRR)

Le partage ou la duplication des fonctionnalités suivantes est spécifique à l'implémentation:

  • IA32_MISC_ENABLE MSR (adresse MSR 1A0H)
  • MSR d'architecture de vérification de la machine (MCA) (sauf pour les MSR IA32_MCG_STATUS et IA32_MCG_CAP)
  • Contrôle de la performance et contrôle des MSR

Le partage de cache est discuté à:

Les hyperthreads Intel ont plus de cache et de partage de pipeline que les cœurs séparés: /superuser/133082/hyper-threading-and-dual-core-whats-the-difference/995858#995858

Noyau Linux 4.2

La principale action d'initialisation semble être à arch/x86/kernel/smpboot.c.

Exemple de baremetal exécutable minimal ARM

Ici, je fournis un exemple exécutable ARMv8 aarch64 minimal pour QEMU:

.global mystart
mystart:
    /* Reset spinlock. */
    mov x0, #0
    ldr x1, =spinlock
    str x0, [x1]

    /* Read cpu id into x1.
     * TODO: cores beyond 4th?
     * Mnemonic: Main Processor ID Register
     */
    mrs x1, mpidr_el1
    ands x1, x1, 3
    beq cpu0_only
cpu1_only:
    /* Only CPU 1 reaches this point and sets the spinlock. */
    mov x0, 1
    ldr x1, =spinlock
    str x0, [x1]
    /* Ensure that CPU 0 sees the write right now.
     * Optional, but could save some useless CPU 1 loops.
     */
    dmb sy
    /* Wake up CPU 0 if it is sleeping on wfe.
     * Optional, but could save power on a real system.
     */
    sev
cpu1_sleep_forever:
    /* Hint CPU 1 to enter low power mode.
     * Optional, but could save power on a real system.
     */
    wfe
    b cpu1_sleep_forever
cpu0_only:
    /* Only CPU 0 reaches this point. */

    /* Wake up CPU 1 from initial sleep!
     * See:https://github.com/cirosantilli/linux-kernel-module-cheat#psci
     */
    /* PCSI function identifier: CPU_ON. */
    ldr w0, =0xc4000003
    /* Argument 1: target_cpu */
    mov x1, 1
    /* Argument 2: entry_point_address */
    ldr x2, =cpu1_only
    /* Argument 3: context_id */
    mov x3, 0
    /* Unused hvc args: the Linux kernel zeroes them,
     * but I don't think it is required.
     */
    hvc 0

spinlock_start:
    ldr x0, spinlock
    /* Hint CPU 0 to enter low power mode. */
    wfe
    cbz x0, spinlock_start

    /* Semihost exit. */
    mov x1, 0x26
    movk x1, 2, lsl 16
    str x1, [sp, 0]
    mov x0, 0
    str x0, [sp, 8]
    mov x1, sp
    mov w0, 0x18
    hlt 0xf000

spinlock:
    .skip 8

GitHub en amont .

Assemblez et exécutez:

aarch64-linux-gnu-gcc \
  -mcpu=cortex-a57 \
  -nostdlib \
  -nostartfiles \
  -Wl,--section-start=.text=0x40000000 \
  -Wl,-N \
  -o aarch64.elf \
  -T link.ld \
  aarch64.S \
;
qemu-system-aarch64 \
  -machine virt \
  -cpu cortex-a57 \
  -d in_asm \
  -kernel aarch64.elf \
  -nographic \
  -semihosting \
  -smp 2 \
;

Dans cet exemple, nous plaçons le CPU 0 dans une boucle de verrou tournant, et il ne se termine que lorsque le CPU 1 libère le verrou tournant.

Après le verrou tournant, le CPU 0 effectue ensuite un appel de sortie semi - hôte qui fait quitter QEMU.

Si vous démarrez QEMU avec un seul processeur -smp 1, la simulation se bloque pour toujours sur le verrou tournant.

Le CPU 1 est réveillé avec l'interface PSCI, plus de détails sur: ARM: Start / Wakeup / Bringup the other CPU cores / APs and pass execution start address?

La version en amont a également quelques ajustements pour la faire fonctionner sur gem5, vous pouvez donc également expérimenter les caractéristiques de performance.

Je ne l'ai pas testé sur du vrai matériel, donc je ne sais pas à quel point c'est portable. La bibliographie Raspberry Pi suivante pourrait être intéressante:

Ce document fournit des conseils sur l'utilisation des primitives de synchronisation ARM que vous pouvez ensuite utiliser pour faire des choses amusantes avec plusieurs cœurs: http://infocenter.arm.com/help/topic/com.arm.doc.dht0008a/DHT0008A_arm_synchronization_primitives.pdf

Testé sur Ubuntu 18.10, GCC 8.2.0, Binutils 2.31.1, QEMU 2.12.0.

Prochaines étapes pour une programmabilité plus pratique

Les exemples précédents réveillent le processeur secondaire et effectuent une synchronisation de base de la mémoire avec des instructions dédiées, ce qui est un bon début.

Mais pour rendre les systèmes multicœurs faciles à programmer, par exemple comme POSIX pthreads , vous devrez également aborder les sujets suivants plus impliqués:

  • l'installation interrompt et exécute un minuteur qui décide périodiquement quel thread s'exécutera maintenant. C'est ce que l'on appelle le multithreading préemptif .

    Ce système doit également enregistrer et restaurer les registres de threads au démarrage et à l'arrêt.

    Il est également possible d'avoir des systèmes multitâches non préemptifs, mais ceux-ci peuvent vous obliger à modifier votre code afin que chaque thread cède (par exemple avec une pthread_yieldimplémentation), et il devient plus difficile d'équilibrer les charges de travail.

    Voici quelques exemples simplifiés de minuterie en métal nu:

  • gérer les conflits de mémoire. Notamment, chaque thread aura besoin d'une pile unique si vous souhaitez coder en C ou dans d'autres langages de haut niveau.

    Vous pouvez simplement limiter les threads pour avoir une taille de pile maximale fixe, mais la meilleure façon de gérer cela est avec la pagination qui permet des piles de "taille illimitée" efficaces.

    Voici un exemple de baremetal naïf aarch64 qui exploserait si la pile devenait trop profonde

Ce sont de bonnes raisons d'utiliser le noyau Linux ou un autre système d'exploitation :-)

Primitives de synchronisation de la mémoire Userland

Bien que le démarrage / arrêt / gestion des threads dépasse généralement la portée de l'espace utilisateur, vous pouvez cependant utiliser les instructions d'assemblage des threads utilisateur pour synchroniser les accès à la mémoire sans appels système potentiellement plus coûteux.

Vous devriez bien sûr préférer utiliser des bibliothèques qui enveloppent de manière portative ces primitives de bas niveau. Le standard C ++ lui-même a fait de grandes avancées sur les en <mutex>- <atomic>têtes et, et en particulier avec std::memory_order. Je ne sais pas si cela couvre toutes les sémantiques de mémoire possibles, mais c'est possible.

La sémantique plus subtile est particulièrement pertinente dans le contexte des structures de données sans verrouillage , qui peuvent offrir des avantages en termes de performances dans certains cas. Pour les implémenter, vous devrez probablement en apprendre un peu plus sur les différents types de barrières mémoire: https://preshing.com/20120710/memory-barriers-are-like-source-control-operations/

Boost, par exemple, propose des implémentations de conteneurs sans verrouillage sur: https://www.boost.org/doc/libs/1_63_0/doc/html/lockfree.html

Ces instructions utilisateur semblent également être utilisées pour implémenter l' futexappel système Linux , qui est l'une des principales primitives de synchronisation sous Linux. man futex4.15 se lit comme suit:

L'appel système futex () fournit une méthode pour attendre qu'une certaine condition devienne vraie. Il est généralement utilisé comme une construction de blocage dans le contexte de la synchronisation de la mémoire partagée. Lors de l'utilisation de futex, la majorité des opérations de synchronisation sont effectuées dans l'espace utilisateur. Un programme de l'espace utilisateur n'utilise l'appel système futex () que lorsqu'il est probable que le programme doive se bloquer plus longtemps jusqu'à ce que la condition devienne vraie. D'autres opérations futex () peuvent être utilisées pour réveiller tous les processus ou threads en attente d'une condition particulière.

Le nom du syscall lui-même signifie "Fast Userspace XXX".

Voici un exemple C ++ x86_64 / aarch64 minimal inutile avec un assemblage en ligne qui illustre l'utilisation de base de ces instructions principalement pour le plaisir:

main.cpp

#include <atomic>
#include <cassert>
#include <iostream>
#include <thread>
#include <vector>

std::atomic_ulong my_atomic_ulong(0);
unsigned long my_non_atomic_ulong = 0;
#if defined(__x86_64__) || defined(__aarch64__)
unsigned long my_arch_atomic_ulong = 0;
unsigned long my_arch_non_atomic_ulong = 0;
#endif
size_t niters;

void threadMain() {
    for (size_t i = 0; i < niters; ++i) {
        my_atomic_ulong++;
        my_non_atomic_ulong++;
#if defined(__x86_64__)
        __asm__ __volatile__ (
            "incq %0;"
            : "+m" (my_arch_non_atomic_ulong)
            :
            :
        );
        // https://github.com/cirosantilli/linux-kernel-module-cheat#x86-lock-prefix
        __asm__ __volatile__ (
            "lock;"
            "incq %0;"
            : "+m" (my_arch_atomic_ulong)
            :
            :
        );
#elif defined(__aarch64__)
        __asm__ __volatile__ (
            "add %0, %0, 1;"
            : "+r" (my_arch_non_atomic_ulong)
            :
            :
        );
        // https://github.com/cirosantilli/linux-kernel-module-cheat#arm-lse
        __asm__ __volatile__ (
            "ldadd %[inc], xzr, [%[addr]];"
            : "=m" (my_arch_atomic_ulong)
            : [inc] "r" (1),
              [addr] "r" (&my_arch_atomic_ulong)
            :
        );
#endif
    }
}

int main(int argc, char **argv) {
    size_t nthreads;
    if (argc > 1) {
        nthreads = std::stoull(argv[1], NULL, 0);
    } else {
        nthreads = 2;
    }
    if (argc > 2) {
        niters = std::stoull(argv[2], NULL, 0);
    } else {
        niters = 10000;
    }
    std::vector<std::thread> threads(nthreads);
    for (size_t i = 0; i < nthreads; ++i)
        threads[i] = std::thread(threadMain);
    for (size_t i = 0; i < nthreads; ++i)
        threads[i].join();
    assert(my_atomic_ulong.load() == nthreads * niters);
    // We can also use the atomics direclty through `operator T` conversion.
    assert(my_atomic_ulong == my_atomic_ulong.load());
    std::cout << "my_non_atomic_ulong " << my_non_atomic_ulong << std::endl;
#if defined(__x86_64__) || defined(__aarch64__)
    assert(my_arch_atomic_ulong == nthreads * niters);
    std::cout << "my_arch_non_atomic_ulong " << my_arch_non_atomic_ulong << std::endl;
#endif
}

GitHub en amont .

Sortie possible:

my_non_atomic_ulong 15264
my_arch_non_atomic_ulong 15267

De cela, nous voyons que l' LDADDinstruction de préfixe x86 LOCK / aarch64 a rendu l'addition atomique: sans elle, nous avons des conditions de concurrence sur de nombreux ajouts, et le nombre total à la fin est inférieur au 20000 synchronisé.

Voir également:

Testé sous Ubuntu 19.04 amd64 et avec le mode utilisateur QEMU aarch64.

Ciro Santilli 郝海东 冠状 病 六四 事件 法轮功
la source
Quel assembleur utilisez-vous pour compiler votre exemple? GAS ne semble pas aimer votre #include(prend cela comme un commentaire), NASM, FASM, YASM ne connaissent pas la syntaxe AT&T donc ça ne peut pas être eux ... alors qu'est-ce que c'est?
Ruslan
@Ruslan gcc, #includeprovient du préprocesseur C. Utilisez le Makefilefourni comme expliqué dans la section de démarrage: github.com/cirosantilli/x86-bare-metal-examples/blob/… Si cela ne fonctionne pas, ouvrez un problème GitHub.
Ciro Santilli 郝海东 冠状 病 六四 事件 法轮功
sur x86, que se passe-t-il si un noyau se rend compte qu'il n'y a plus de processus prêts à s'exécuter dans la file d'attente? (ce qui peut arriver de temps en temps sur un système inactif). Le noyau tourne-t-il sur la structure de la mémoire partagée jusqu'à ce qu'il y ait une nouvelle tâche? (probablement pas bon, il utilisera beaucoup d'énergie) appelle-t-il quelque chose comme HLT à dormir jusqu'à ce qu'il y ait une interruption? (dans ce cas, qui est responsable de réveiller ce noyau?)
tigrou
@tigrou n'est pas sûr, mais je trouve extrêmement probable que l'implémentation Linux le mettra en état d'alimentation jusqu'à la prochaine interruption (probable minuterie), en particulier sur ARM où l'alimentation est la clé. J'essaierais rapidement de voir si cela peut être observé concrètement facilement avec une trace d'instruction d'un simulateur fonctionnant sous Linux, cela pourrait être: github.com/cirosantilli/linux-kernel-module-cheat/tree/…
Ciro Santilli 郝海东 冠状 病六四 事件 法轮功
1
Certaines informations (spécifiques à x86 / Windows) peuvent être trouvées ici (voir "Thread inactif"). TL; DR: quand aucun thread exécutable n'existe sur un CPU, le CPU est distribué à un thread inactif. Parallèlement à certaines autres tâches, il appellera finalement la routine inactive du processeur de gestion de l'alimentation enregistrée (via un pilote fourni par le vendeur de CPU, par exemple: Intel). Cela pourrait faire passer le processeur à un état C plus profond (par exemple: C0 -> C3) afin de réduire la consommation d'énergie.
tigrou
43

Si je comprends bien, chaque "cœur" est un processeur complet, avec son propre ensemble de registres. Fondamentalement, le BIOS vous démarre avec un cœur en cours d'exécution, puis le système d'exploitation peut "démarrer" d'autres cœurs en les initialisant et en les pointant sur le code à exécuter, etc.

La synchronisation est effectuée par le système d'exploitation. Généralement, chaque processeur exécute un processus différent pour le système d'exploitation, de sorte que la fonctionnalité multithread du système d'exploitation est chargée de décider quel processus doit toucher quelle mémoire et quoi faire en cas de collision de mémoire.

Nicholas Flynt
la source
28
ce qui pose la question cependant: quelles instructions sont disponibles pour le système d'exploitation?
Paul Hollingsworth
4
Il y a un ensemble d'instructions privilégiées pour cela, mais c'est le problème du système d'exploitation, pas le code d'application. Si le code d'application veut être multithread, il doit appeler les fonctions du système d'exploitation pour faire la "magie".
sharptooth
2
Le BIOS identifie généralement le nombre de cœurs disponibles et transmet ces informations au système d'exploitation à la demande. Il existe des normes auxquelles le BIOS (et le matériel) doit se conformer, de sorte que l'accès aux spécificités matérielles (processeurs, cœurs, bus PCI, cartes PCI, souris, clavier, graphiques, ISA, PCI-E / X, mémoire, etc.) pour différents PC est identique du point de vue du système d'exploitation. Si le BIOS ne signale pas qu'il y a quatre cœurs, le système d'exploitation supposera généralement qu'il n'y en a qu'un. Il pourrait même y avoir un paramètre du BIOS à expérimenter.
Olof Forshell
1
C'est cool et tout, mais que faire si vous écrivez un programme bare-metal?
Alexander Ryan Baggett
3
@AlexanderRyanBaggett,? Qu'est-ce que c'est encore? Réitérant, lorsque nous disons «laissez-le à l'OS», nous évitons la question parce que la question est de savoir comment l'OS le fait alors? Quelles instructions de montage utilise-t-il?
Pacerier
39

La FAQ non officielle de SMP logo de débordement de pile


Il était une fois, pour écrire un assembleur x86, par exemple, vous auriez des instructions indiquant "charger le registre EDX avec la valeur 5", "incrémenter le registre EDX", etc. Avec les processeurs modernes qui ont 4 cœurs (ou même plus) , au niveau du code machine, semble-t-il simplement qu'il y a 4 CPU séparés (c'est-à-dire qu'il n'y a que 4 registres "EDX" distincts)?

Exactement. Il existe 4 jeux de registres, dont 4 pointeurs d'instructions distincts.

Si tel est le cas, lorsque vous dites "incrémenter le registre EDX", qu'est-ce qui détermine le registre EDX du processeur incrémenté?

Le CPU qui a exécuté cette instruction, naturellement. Considérez-le comme 4 microprocesseurs entièrement différents qui partagent simplement la même mémoire.

Existe-t-il maintenant un concept de «contexte CPU» ou de «thread» dans l'assembleur x86?

Non. L'assembleur traduit simplement les instructions comme il l'a toujours fait. Aucun changement là-bas.

Comment fonctionne la communication / synchronisation entre les cœurs?

Puisqu'ils partagent la même mémoire, c'est principalement une question de logique de programme. Bien qu'il y ait maintenant une interruption inter-processeur mécanisme d' , il n'est pas nécessaire et n'était pas présent à l'origine dans les premiers systèmes x86 à double processeur.

Si vous écriviez un système d'exploitation, quel mécanisme est exposé via le matériel pour vous permettre de planifier l'exécution sur différents cœurs?

L'ordonnanceur ne change pas, sauf qu'il porte un peu plus attention aux sections critiques et aux types de verrous utilisés. Avant SMP, le code du noyau finissait par appeler le planificateur, qui regardait la file d'attente d'exécution et choisissait un processus à exécuter en tant que thread suivant. (Les processus vers le noyau ressemblent beaucoup à des threads.) Le noyau SMP exécute exactement le même code, un thread à la fois, c'est juste que le verrouillage des sections critiques doit maintenant être protégé par SMP pour être sûr que deux cœurs ne peuvent pas accidentellement choisir le même PID.

S'agit-il d'instructions privilégiées spéciales?

Non. Les cœurs fonctionnent tous dans la même mémoire avec les mêmes anciennes instructions.

Si vous écriviez une machine virtuelle de compilation / bytecode d'optimisation pour un processeur multicœur, que devriez-vous savoir spécifiquement sur, disons, x86 pour lui faire générer du code qui s'exécute efficacement sur tous les cœurs?

Vous exécutez le même code que précédemment. C'est le noyau Unix ou Windows qui devait changer.

Vous pouvez résumer ma question comme suit: "Quelles modifications ont été apportées au code machine x86 pour prendre en charge la fonctionnalité multicœur?"

Rien n'était nécessaire. Les premiers systèmes SMP utilisaient exactement le même jeu d'instructions que les monoprocesseurs. Maintenant, il y a eu beaucoup d'évolution de l'architecture x86 et des millions de nouvelles instructions pour accélérer les choses, mais aucune n'était nécessaire pour SMP.

Pour plus d'informations, consultez le spécification Intel Multiprocessor .


Mise à jour: toutes les questions de suivi peuvent être répondues en acceptant complètement qu'un processeur multicœur à n voies est presque 1 exactement la même chose que n processeurs séparés qui partagent juste la même mémoire. 2 Une question importante n'a pas été posée: comment un programme est-il écrit pour s'exécuter sur plusieurs cœurs pour plus de performances? Et la réponse est: il est écrit en utilisant une bibliothèque de threads comme Pthreads. Certaines bibliothèques de threads utilisent des «threads verts» qui ne sont pas visibles par le système d'exploitation, et ceux-ci n'obtiendront pas de cœurs séparés, mais tant que la bibliothèque de threads utilise des fonctionnalités de thread du noyau, votre programme de thread sera automatiquement multicœur.
1. Pour des raisons de compatibilité ascendante, seul le premier noyau démarre lors de la réinitialisation, et quelques tâches de type pilote doivent être effectuées pour activer les autres.
2. Ils partagent également tous les périphériques, naturellement.

DigitalRoss
la source
3
Je pense toujours que "thread" est un concept logiciel, ce qui me rend difficile à comprendre le processeur multi-core, le problème est, comment les codes peuvent-ils dire à un core "je vais créer un thread fonctionnant dans le core 2"? Existe-t-il un code d'assemblage spécial pour le faire?
demonguy
2
@demonguy: Non, il n'y a pas d'instructions spéciales pour quelque chose comme ça. Vous demandez au système d'exploitation d'exécuter votre thread sur un noyau spécifique en définissant un masque d'affinité (qui indique "ce thread peut s'exécuter sur cet ensemble de cœurs logiques"). C'est complètement un problème logiciel. Chaque cœur de processeur (thread matériel) exécute indépendamment Linux (ou Windows). Pour fonctionner avec les autres threads matériels, ils utilisent des structures de données partagées. Mais vous ne démarrez jamais "directement" un thread sur un autre processeur. Vous dites à l'OS que vous souhaitez avoir un nouveau thread, et cela fait une note dans une structure de données que l'OS sur un autre noyau voit.
Peter Cordes
2
Je peux le dire, mais comment mettre des codes sur un noyau spécifique?
demonguy
4
@demonguy ... (simplifié) ... chaque cœur partage l'image du système d'exploitation et commence à l'exécuter au même endroit. Donc, pour 8 cœurs, c'est 8 "processus matériels" en cours d'exécution dans le noyau. Chacun appelle la même fonction de planificateur qui vérifie la table de processus pour un processus ou un thread exécutable. (C'est la file d'attente d'exécution. ) Pendant ce temps, les programmes avec des threads fonctionnent sans se rendre compte de la nature SMP sous-jacente. Ils fourchent simplement (2) ou quelque chose et indiquent au noyau qu'ils veulent s'exécuter. Essentiellement, le noyau trouve le processus, plutôt que le processus trouvant le noyau.
DigitalRoss
1
Vous n'avez en fait pas besoin d'interrompre un cœur d'un autre. Pensez-y de cette façon: tout ce dont vous aviez besoin pour communiquer auparavant était très bien communiqué avec les mécanismes logiciels. Les mêmes mécanismes logiciels continuent de fonctionner. Donc, les tuyaux, les appels du noyau, le sommeil / réveil, tout ça ... ils fonctionnent toujours comme avant. Tous les processus ne s'exécutent pas sur le même processeur, mais ils ont les mêmes structures de données pour la communication qu'avant. L'effort pour devenir SMP se limite principalement à faire fonctionner les anciennes serrures dans un environnement plus parallèle.
DigitalRoss
10

Si vous écriviez une machine virtuelle de compilation / bytecode d'optimisation pour un processeur multicœur, que devriez-vous savoir spécifiquement sur, disons, x86 pour lui faire générer du code qui s'exécute efficacement sur tous les cœurs?

En tant que personne qui écrit l'optimisation de machines virtuelles de compilation / bytecode, je peux peut-être vous aider ici.

Vous n'avez besoin de rien savoir spécifiquement sur x86 pour lui faire générer du code qui s'exécute efficacement sur tous les cœurs.

Cependant, vous devrez peut-être connaître cmpxchg et ses amis pour écrire du code qui s'exécute correctement sur tous les cœurs. La programmation multicœur nécessite l'utilisation de la synchronisation et de la communication entre les threads d'exécution.

Vous devrez peut-être connaître quelque chose sur x86 pour lui faire générer du code qui s'exécute efficacement sur x86 en général.

Il y a d'autres choses qu'il vous serait utile d'apprendre:

Vous devez en savoir plus sur les fonctionnalités du système d'exploitation (Linux ou Windows ou OSX) pour vous permettre d'exécuter plusieurs threads. Vous devriez en apprendre davantage sur les API de parallélisation telles que OpenMP et Threading Building Blocks, ou le prochain "Grand Central" d'OSX 10.6 "Snow Leopard".

Vous devez déterminer si votre compilateur doit être auto-parallélisant ou si l'auteur des applications compilées par votre compilateur doit ajouter une syntaxe spéciale ou des appels d'API dans son programme pour tirer parti des multiples cœurs.

Alex Brown
la source
Vous n'avez pas plusieurs machines virtuelles populaires comme .NET et Java qui ont un problème que leur processus GC principal soit couvert par des verrous et fondamentalement monothreaded?
Marco van de Voort
9

Chaque Core s'exécute à partir d'une zone mémoire différente. Votre système d'exploitation pointera un noyau vers votre programme et le noyau exécutera votre programme. Votre programme ne saura pas qu'il existe plusieurs cœurs ou sur quel cœur il s'exécute.

Il n'y a également aucune instruction supplémentaire disponible uniquement pour le système d'exploitation. Ces cœurs sont identiques aux puces à cœur unique. Chaque noyau exécute une partie du système d'exploitation qui gérera la communication avec les zones de mémoire communes utilisées pour l'échange d'informations afin de trouver la prochaine zone de mémoire à exécuter.

Il s'agit d'une simplification, mais elle vous donne une idée de base de la façon dont cela est fait. Plus d'informations sur les multicœurs et les multiprocesseurs sur Embedded.com contient de nombreuses informations sur ce sujet ... Ce sujet se complique très rapidement!

Gerhard
la source
Je pense que l'on devrait distinguer un peu plus attentivement ici comment le multicœur fonctionne en général, et combien l'influence du système d'exploitation. «Chaque cœur s'exécute à partir d'une zone de mémoire différente» est trop trompeur à mon avis. Tout d'abord, l'utilisation de plusieurs cœurs dans les principes n'a pas besoin de cela, et vous pouvez facilement voir que pour un programme threadé, vous VOULEZ que deux cœurs travaillent sur les mêmes segments de texte et de données (tandis que chaque cœur a également besoin de ressources individuelles comme la pile) .
Volker Stolz
@ShiDoiSi C'est pourquoi ma réponse contient le texte "Ceci est une simplification" .
Gerhard
5

Le code assembleur se traduira en code machine qui sera exécuté sur un cœur. Si vous voulez qu'il soit multithread, vous devrez utiliser plusieurs fois les primitives du système d'exploitation pour démarrer ce code sur différents processeurs ou différents morceaux de code sur différents cœurs - chaque cœur exécutera un thread distinct. Chaque thread ne verra qu'un seul noyau sur lequel il s'exécute actuellement.

acéré
la source
4
J'allais dire quelque chose comme ça, mais comment le système d'exploitation alloue-t-il les threads aux cœurs? J'imagine qu'il existe des instructions de montage privilégiées qui accomplissent cela. Si c'est le cas, je pense que c'est la réponse que cherche l'auteur.
A. Levy
Il n'y a aucune instruction pour cela, c'est le devoir du planificateur du système d'exploitation. Il existe des fonctions de système d'exploitation comme SetThreadAffinityMask dans Win32 et le code peut les appeler, mais c'est du système d'exploitation et affecte le planificateur, ce n'est pas une instruction de processeur.
sharptooth
2
Il doit y avoir un OpCode, sinon le système d'exploitation ne pourrait pas le faire non plus.
Matthew Whited
1
Pas vraiment un opcode pour la planification - c'est plus comme si vous obteniez une copie du système d'exploitation par processeur, partageant un espace mémoire; chaque fois qu'un core rentre dans le noyau (syscall ou interruption), il regarde les mêmes structures de données en mémoire pour décider quel thread exécuter ensuite.
pjc50
1
@ A.Levy: Lorsque vous démarrez un thread avec une affinité qui ne le laisse s'exécuter que sur un core différent, il ne se déplace pas immédiatement vers l'autre core. Il a son contexte enregistré en mémoire, tout comme un changement de contexte normal. Les autres threads matériels voient son entrée dans les structures de données du planificateur, et l'un d'eux décidera éventuellement d'exécuter le thread. Donc, du point de vue du premier noyau: vous écrivez dans une structure de données partagée et, éventuellement, le code du système d'exploitation sur un autre noyau (thread matériel) le remarquera et l'exécutera.
Peter Cordes
3

Cela ne se fait pas du tout dans les instructions de la machine; les cœurs prétendent être des processeurs distincts et n'ont pas de capacités spéciales pour communiquer entre eux. Il y a deux façons de communiquer:

  • ils partagent l'espace d'adressage physique. Le matériel gère la cohérence du cache, donc une CPU écrit dans une adresse mémoire qu'une autre lit.

  • ils partagent un APIC (contrôleur d'interruption programmable). Il s'agit de mémoire mappée dans l'espace d'adressage physique et peut être utilisée par un processeur pour contrôler les autres, les activer ou les désactiver, envoyer des interruptions, etc.

http://www.cheesecake.org/sac/smp.html est une bonne référence avec une URL idiote.

pjc50
la source
2
En fait, ils ne partagent pas un APIC. Chaque CPU logique en possède une. Les APIC communiquent entre eux, mais ils sont séparés.
Nathan Fellman
Ils se synchronisent (plutôt que de communiquer) d'une manière de base et c'est à travers le préfixe LOCK (l'instruction "xchg mem, reg" contient une demande de verrouillage implicite) qui s'exécute sur la broche de verrouillage qui s'exécute sur tous les bus en leur indiquant effectivement que le CPU (en fait, tout dispositif de maîtrise de bus) souhaite un accès exclusif au bus. Finalement, un signal reviendra à la broche LOCKA (accusé de réception) indiquant au CPU qu'il a désormais un accès exclusif au bus. Étant donné que les périphériques externes sont beaucoup plus lents que le fonctionnement interne du CPU, une séquence LOCK / LOCKA peut nécessiter plusieurs centaines de cycles CPU pour se terminer.
Olof Forshell
1

La principale différence entre une application simple et une application multithread est que la première a une pile et que la seconde en a une pour chaque thread. Le code est généré quelque peu différemment car le compilateur supposera que les registres de données et de segments de pile (ds et ss) ne sont pas égaux. Cela signifie que l'indirection via les registres ebp et esp qui par défaut au registre ss ne sera pas également par défaut à ds (car ds! = Ss). Inversement, l'indirection via les autres registres qui par défaut est ds ne sera pas par défaut ss.

Les threads partagent tout le reste, y compris les zones de données et de code. Ils partagent également des routines lib alors assurez-vous qu'ils sont thread-safe. Une procédure qui trie une zone en RAM peut être multithread pour accélérer les choses. Les threads accèderont, compareront et ordonneront des données dans la même zone de mémoire physique et exécuteront le même code mais en utilisant différentes variables locales pour contrôler leur partie respective du tri. C'est bien sûr parce que les threads ont des piles différentes où les variables locales sont contenues. Ce type de programmation nécessite un réglage minutieux du code afin de réduire les collisions de données entre les cœurs (dans les caches et la RAM), ce qui entraîne à son tour un code plus rapide avec deux threads ou plus qu'avec un seul. Bien sûr, un code non réglé sera souvent plus rapide avec un processeur qu'avec deux ou plus. Déboguer est plus difficile car le point d'arrêt standard "int 3" ne sera pas applicable car vous voulez interrompre un thread spécifique et pas tous. Les points d'arrêt du registre de débogage ne résolvent pas non plus ce problème, sauf si vous pouvez les définir sur le processeur spécifique exécutant le thread spécifique que vous souhaitez interrompre.

D'autres codes multithread peuvent impliquer différents threads s'exécutant dans différentes parties du programme. Ce type de programmation ne nécessite pas le même type de réglage et est donc beaucoup plus facile à apprendre.

Olof Forshell
la source
0

Ce qui a été ajouté sur chaque architecture multiprocesseur par rapport aux variantes mono-processeur qui les ont précédées, ce sont les instructions de synchronisation entre les cœurs. De plus, vous avez des instructions pour gérer la cohérence du cache, vider les tampons et les opérations de bas niveau similaires qu'un système d'exploitation doit gérer. Dans le cas d'architectures multithreads simultanées comme IBM POWER6, IBM Cell, Sun Niagara et Intel "Hyperthreading", vous avez également tendance à voir de nouvelles instructions pour hiérarchiser les threads (comme définir des priorités et céder explicitement le processeur lorsqu'il n'y a rien à faire) .

Mais la sémantique de base sur un seul thread est la même, vous ajoutez simplement des fonctionnalités supplémentaires pour gérer la synchronisation et la communication avec d'autres cœurs.

jakobengblom2
la source