Era uma vez, para escrever um montador x86, por exemplo, você teria instruções dizendo "carregar o registro EDX com o valor 5", "incrementar o registro EDX", etc.
Com CPUs modernas que possuem 4 núcleos (ou mais), no nível do código da máquina, parece que existem 4 CPUs separadas (ou seja, existem apenas 4 registros "EDX" distintos)? Se sim, quando você diz "incrementar o registro EDX", o que determina qual registro EDX da CPU é incrementado? Existe um conceito de "contexto de CPU" ou "thread" no assembler x86 agora?
Como a comunicação / sincronização entre os núcleos funciona?
Se você estava escrevendo um sistema operacional, qual mecanismo é exposto por hardware para permitir agendar a execução em diferentes núcleos? É alguma instrução especial privilegiada?
Se você estivesse escrevendo uma VM otimizada de compilador / bytecode para uma CPU multicore, o que você precisaria saber especificamente sobre, por exemplo, x86 para gerar um código que é executado de maneira eficiente em todos os núcleos?
Quais alterações foram feitas no código de máquina x86 para oferecer suporte à funcionalidade multinúcleo?
Respostas:
Esta não é uma resposta direta à pergunta, mas é uma resposta a uma pergunta que aparece nos comentários. Essencialmente, a questão é qual o suporte que o hardware oferece à operação multithread.
Nicholas Flynt estava certo , pelo menos em relação ao x86. Em um ambiente multithread (Hyper-threading, multi-core ou multiprocessador), o thread do Bootstrap (normalmente o thread 0 no núcleo 0 no processador 0) inicia a busca do código do endereço
0xfffffff0
. Todos os outros encadeamentos iniciam em um estado de suspensão especial chamado Aguardar SIPI . Como parte de sua inicialização, o encadeamento primário envia uma IPI (interrupção entre processadores) especial sobre o APIC chamado SIPI (IPI de inicialização) para cada encadeamento que está no WFS. O SIPI contém o endereço do qual esse encadeamento deve começar a buscar código.Esse mecanismo permite que cada thread execute o código de um endereço diferente. Tudo o que é necessário é suporte de software para cada thread para configurar suas próprias tabelas e filas de mensagens. O sistema operacional usa os a fazer a programação multi-thread real.
No que diz respeito à montagem real, como Nicholas escreveu, não há diferença entre as montagens para um único aplicativo com ou sem rosca. Cada encadeamento lógico possui seu próprio conjunto de registros, portanto, escrevendo:
será atualizado apenas
EDX
para o segmento em execução no momento . Não há como modificarEDX
em outro processador usando uma única instrução de montagem. Você precisa de algum tipo de chamada do sistema para solicitar ao sistema operacional que diga a outro thread para executar o código que será atualizado automaticamenteEDX
.fonte
Exemplo de baremetal mínimo executável da Intel x86
Exemplo de metal nu executável com todos os padrões exigidos . Todas as principais partes são abordadas abaixo.
Testado no Ubuntu 15.10 QEMU 2.3.0 e no convidado de hardware real Lenovo ThinkPad T400 .
O Guia de programação do sistema Intel Manual Volume 3 - 325384-056BR setembro de 2015 aborda o SMP nos capítulos 8, 9 e 10.
Tabela 8-1. "Transmitir sequência INIT-SIPI-SIPI e escolha de tempos limite" contém um exemplo que basicamente funciona:
Nesse código:
A maioria dos sistemas operacionais impossibilitará a maioria dessas operações do anel 3 (programas do usuário).
Então você precisa escrever seu próprio kernel para brincar livremente com ele: um programa Linux do usuário não funcionará.
Inicialmente, um único processador é executado, chamado de processador de bootstrap (BSP).
Ele deve ativar os outros (chamados Application Processors (AP)) através de interrupções especiais chamadas Inter Processor Interrupts (IPI) .
Essas interrupções podem ser feitas através da programação de Controlador de interrupção programável avançado (APIC) por meio do registro de comando de interrupção (ICR)
O formato do ICR está documentado em: 10.6 "EMITIR INTERRUPTORES DE INTERPROCESSADORES"
O IPI acontece assim que escrevemos para o ICR.
ICR_LOW é definido em 8.4.4 "Exemplo de Inicialização MP" como:
O valor mágico
0FEE00300
é o endereço de memória do ICR, conforme documentado na Tabela 10-1 "Mapa local de endereços de registro APIC"O método mais simples possível é usado no exemplo: ele configura o ICR para enviar IPIs de difusão que são entregues a todos os outros processadores, exceto o atual.
Mas também é possível, e recomendado por alguns , obter informações sobre os processadores por meio de estruturas de dados especiais configuradas pelo BIOS, como tabelas ACPI ou tabela de configuração MP da Intel, e apenas ativar os que você precisa um por um.
XX
in000C46XXH
codifica o endereço da primeira instrução que o processador executará como:Lembre-se de que o CS multiplica endereços por
0x10
, portanto, o endereço de memória real da primeira instrução é:Portanto, se, por exemplo
XX == 1
, o processador iniciar às0x1000
.Devemos garantir que haja um código de modo real de 16 bits para ser executado nesse local de memória, por exemplo, com:
Usar um script vinculador é outra possibilidade.
Os loops de atraso são uma parte chata para começar a trabalhar: não há uma maneira super simples de fazer essas dormidas com precisão.
Os métodos possíveis incluem:
Relacionado: Como exibir um número na tela e dormir por um segundo com a montagem do DOS x86?
Eu acho que o processador inicial precisa estar no modo protegido para que isso funcione enquanto escrevemos para o endereço
0FEE00300H
que é muito alto para 16 bitsPara se comunicar entre processadores, podemos usar um spinlock no processo principal e modificar o bloqueio a partir do segundo núcleo.
Devemos garantir que a gravação de memória seja feita, por exemplo, através
wbinvd
.Estado compartilhado entre processadores
8.7.1 "Estado dos processadores lógicos" diz:
O compartilhamento de cache é discutido em:
Os hyperthreads da Intel têm maior compartilhamento de cache e pipeline do que núcleos separados: /superuser/133082/hyper-threading-and-dual-core-whats-the-difference/995858#995858
Kernel Linux 4.2
A principal ação de inicialização parece estar em
arch/x86/kernel/smpboot.c
.Exemplo mínimo de baremetal executável do ARM
Aqui, forneço um exemplo mínimo de ARMv8 aarch64 executável para QEMU:
GitHub upstream .
Montar e executar:
Neste exemplo, colocamos a CPU 0 em um loop de spinlock, e ele só sai com a CPU 1 libera o spinlock.
Após o spinlock, a CPU 0 faz uma chamada de saída de semi - host que faz com que o QEMU saia.
Se você iniciar o QEMU com apenas uma CPU
-smp 1
, a simulação ficará suspensa para sempre no spinlock.A CPU 1 é acordada com a interface PSCI, mais detalhes em: ARM: Iniciar / Ativar / Recuperar os outros núcleos / APs da CPU e passar o endereço inicial de execução?
A versão upstream também possui alguns ajustes para fazê-lo funcionar no gem5, para que você também possa experimentar as características de desempenho.
Eu não o testei em hardware real, então não tenho certeza de como isso é portátil. A seguinte bibliografia do Raspberry Pi pode ser interessante:
Este documento fornece algumas orientações sobre o uso de primitivas de sincronização ARM, que você pode usar para fazer coisas divertidas com vários núcleos: http://infocenter.arm.com/help/topic/com.arm.doc.dht0008a/DHT0008A_arm_synchronization_primitives.pdf
Testado no Ubuntu 18.10, GCC 8.2.0, Binutils 2.31.1, QEMU 2.12.0.
Próximas etapas para uma programação mais conveniente
Os exemplos anteriores ativam a CPU secundária e fazem a sincronização básica da memória com instruções dedicadas, o que é um bom começo.
Mas, para facilitar a programação de sistemas multicore, por exemplo, como o POSIX
pthreads
, você também precisará entrar nos seguintes tópicos mais envolvidos:A instalação interrompe e executa um cronômetro que decide periodicamente qual thread será executado agora. Isso é conhecido como multithreading preventivo .
Esse sistema também precisa salvar e restaurar registros de encadeamento à medida que são iniciados e parados.
Também é possível ter sistemas multitarefa não-preemptivos, mas isso pode exigir que você modifique seu código para que todos os encadeamentos produzam (por exemplo, com uma
pthread_yield
implementação), e fica mais difícil equilibrar as cargas de trabalho.Aqui estão alguns exemplos simplistas do temporizador bare metal:
lidar com conflitos de memória. Notavelmente, cada thread precisará de uma pilha exclusiva se você quiser codificar em C ou em outros idiomas de alto nível.
Você pode limitar os encadeamentos para ter um tamanho máximo fixo de pilha, mas a melhor maneira de lidar com isso é com a paginação, que permite pilhas eficientes de "tamanho ilimitado".
Aqui está um exemplo baremetal ingênuo do aarch64 que explodiria se a pilha crescesse muito fundo
Essas são algumas boas razões para usar o kernel do Linux ou algum outro sistema operacional :-)
Primitivas de sincronização de memória do Userland
Embora o início / parada / gerenciamento do encadeamento esteja geralmente fora do escopo da área do usuário, você pode, no entanto, usar instruções de montagem dos encadeamentos da área do usuário para sincronizar os acessos à memória sem chamadas de sistema potencialmente mais caras.
Obviamente, você deve preferir usar bibliotecas que agrupem essas primitivas de baixo nível. O padrão C ++ si fez grandes avanços nos
<mutex>
e<atomic>
cabeçalhos, e em particular comstd::memory_order
. Não tenho certeza se ele cobre todas as semânticas de memória possíveis, mas apenas pode.A semântica mais sutil é particularmente relevante no contexto de estruturas de dados sem bloqueio , que podem oferecer benefícios de desempenho em certos casos. Para implementá-las, você provavelmente precisará aprender um pouco sobre os diferentes tipos de barreiras de memória: https://preshing.com/20120710/memory-barriers-are-like-source-control-operations/
O Boost, por exemplo, tem algumas implementações de contêiner sem bloqueio em: https://www.boost.org/doc/libs/1_63_0/doc/html/lockfree.html
Essas instruções da terra do usuário também parecem ser usadas para implementar a
futex
chamada do sistema Linux , que é uma das principais primitivas de sincronização no Linux.man futex
4,15 lê:O próprio nome do syscall significa "Fast Userspace XXX".
Aqui está um exemplo mínimo inútil de C ++ x86_64 / aarch64 com assembly embutido que ilustra o uso básico dessas instruções principalmente por diversão:
main.cpp
GitHub upstream .
Saída possível:
A partir disso, vemos que a
LDADD
instrução x86 LOCK prefix / aarch64 tornou a adição atômica: sem ela, temos condições de corrida em muitas das adições, e a contagem total no final é menor que a 20000 sincronizada.Veja também:
Testado no Ubuntu 19.04 amd64 e com o modo de usuário QEMU aarch64.
fonte
#include
(toma como comentário), NASM, FASM, YASM não conhecem a sintaxe da AT&T, portanto não podem ser eles ... então o que é?gcc
,#include
vem do pré-processador C. Use oMakefile
fornecido conforme explicado na seção de introdução : github.com/cirosantilli/x86-bare-metal-examples/blob/… Se isso não funcionar, abra um problema do GitHub.Pelo que entendi, cada "núcleo" é um processador completo, com seu próprio conjunto de registros. Basicamente, o BIOS inicia você com um núcleo em execução e, em seguida, o sistema operacional pode "iniciar" outros núcleos, inicializando-os e apontando-os para o código a ser executado, etc.
A sincronização é feita pelo sistema operacional. Geralmente, cada processador está executando um processo diferente para o sistema operacional; portanto, a funcionalidade de multiencadeamento do sistema operacional é responsável por decidir qual processo tocará em qual memória e o que fazer no caso de uma colisão de memória.
fonte
Perguntas freqüentes sobre o SMP não oficial
Era uma vez, para escrever um montador x86, por exemplo, você teria instruções dizendo "carregar o registro EDX com o valor 5", "incrementar o registro EDX" etc. etc. Com CPUs modernas que possuem 4 núcleos (ou mais) , no nível do código da máquina, parece que existem 4 CPUs separadas (ou seja, existem apenas 4 registros "EDX" distintos)?
Exatamente. Existem 4 conjuntos de registros, incluindo 4 ponteiros de instruções separados.
Se sim, quando você diz "incrementar o registro EDX", o que determina qual registro EDX da CPU é incrementado?
A CPU que executou essa instrução, naturalmente. Pense nisso como 4 microprocessadores completamente diferentes que simplesmente compartilham a mesma memória.
Existe um conceito de "contexto de CPU" ou "thread" no assembler x86 agora?
Não. O montador apenas traduz instruções como sempre. Não há alterações lá.
Como a comunicação / sincronização entre os núcleos funciona?
Como eles compartilham a mesma memória, é principalmente uma questão de lógica do programa. Embora exista agora um mecanismo de interrupção entre processadores , não é necessário e não estava originalmente presente nos primeiros sistemas x86 de CPU dupla.
Se você estava escrevendo um sistema operacional, qual mecanismo é exposto por hardware para permitir agendar a execução em diferentes núcleos?
O agendador, na verdade, não muda, exceto que é um pouco mais cuidadoso sobre as seções críticas e os tipos de bloqueios usados. Antes do SMP, o código do kernel eventualmente chamava o agendador, que examinaria a fila de execução e escolheria um processo para executar como o próximo encadeamento. (Os processos no kernel se parecem muito com threads.) O kernel SMP executa exatamente o mesmo código, um thread de cada vez, mas agora o bloqueio de seção crítico precisa ser seguro para SMP para garantir que dois núcleos não possam escolher acidentalmente o mesmo PID.
É alguma instrução especial privilegiada?
Não. Os núcleos estão todos rodando na mesma memória com as mesmas instruções antigas.
Se você estivesse escrevendo uma VM otimizada de compilador / bytecode para uma CPU multicore, o que você precisaria saber especificamente sobre, por exemplo, x86 para gerar um código que é executado de maneira eficiente em todos os núcleos?
Você executa o mesmo código que antes. É o kernel do Unix ou Windows que precisava mudar.
Você pode resumir minha pergunta como "Quais alterações foram feitas no código da máquina x86 para oferecer suporte à funcionalidade multinúcleo?"
Nada foi necessário. Os primeiros sistemas SMP usavam exatamente o mesmo conjunto de instruções dos uniprocessadores. Agora, houve uma grande evolução na arquitetura x86 e zilhões de novas instruções para acelerar as coisas, mas nenhuma era necessária para o SMP.
Para obter mais informações, consulte a especificação do multiprocessador Intel .
Atualização: todas as perguntas a seguir podem ser respondidas aceitando completamente que uma CPU multicore n- way é quase 1 exatamente a mesma coisa que n processadores separados que compartilham a mesma memória. 2 Havia uma pergunta importante não feita: como um programa é escrito para ser executado em mais de um núcleo para obter mais desempenho? E a resposta é: ela é escrita usando uma biblioteca de threads como Pthreads. Algumas bibliotecas de encadeamentos usam "encadeamentos verdes" que não são visíveis para o sistema operacional, e esses não terão núcleos separados, mas enquanto a biblioteca de encadeamentos usar recursos de encadeamento do kernel, seu programa encadeado será automaticamente multicore.
1. Para compatibilidade com versões anteriores, apenas o primeiro núcleo inicia na redefinição e algumas coisas do tipo driver precisam ser feitas para ativar os restantes.
2. Eles também compartilham todos os periféricos, naturalmente.
fonte
Como alguém que escreve otimizando VMs de compilador / bytecode, talvez eu possa ajudá-lo aqui.
Você não precisa saber nada especificamente sobre o x86 para gerar um código que seja executado com eficiência em todos os núcleos.
No entanto, talvez você precise conhecer cmpxchg e amigos para escrever um código que seja executado corretamente em todos os núcleos. A programação multicore requer o uso de sincronização e comunicação entre os threads de execução.
Pode ser necessário saber algo sobre o x86 para gerar um código que seja executado com eficiência no x86 em geral.
Há outras coisas que seriam úteis para você aprender:
Você deve aprender sobre os recursos que o SO (Linux ou Windows ou OSX) fornece para permitir a execução de vários threads. Você deve aprender sobre APIs de paralelização, como OpenMP e Threading Building Blocks, ou o próximo "Grand Central" do OSX 10.6 "Snow Leopard".
Você deve considerar se o compilador deve ser paralelizado automaticamente ou se o autor dos aplicativos compilados pelo compilador precisa adicionar sintaxe especial ou chamadas de API ao programa para tirar proveito dos vários núcleos.
fonte
Cada núcleo é executado a partir de uma área de memória diferente. Seu sistema operacional apontará um núcleo para seu programa e o núcleo executará seu programa. Seu programa não estará ciente de que há mais de um núcleo ou em qual núcleo ele está executando.
Também não há instruções adicionais disponíveis apenas para o sistema operacional. Esses núcleos são idênticos aos chips de núcleo único. Cada núcleo executa uma parte do sistema operacional que manipulará a comunicação com áreas de memória comuns usadas para o intercâmbio de informações para encontrar a próxima área de memória a ser executada.
Isso é uma simplificação, mas fornece a idéia básica de como isso é feito. Mais sobre multicores e multiprocessadores no Embedded.com tem muitas informações sobre este tópico ... Este tópico fica complicado muito rapidamente!
fonte
O código de montagem será convertido em código de máquina que será executado em um núcleo. Se você deseja que ele seja multithread, você precisará usar as primitivas do sistema operacional para iniciar esse código em diferentes processadores várias vezes ou diferentes partes de código em núcleos diferentes - cada núcleo executará um encadeamento separado. Cada thread verá apenas um núcleo no qual está sendo executado atualmente.
fonte
Isso não é feito nas instruções da máquina; os núcleos fingem ser CPUs distintas e não possuem recursos especiais para conversar entre si. Existem duas maneiras de se comunicar:
eles compartilham o espaço de endereço físico. O hardware lida com a coerência do cache; portanto, uma CPU grava em um endereço de memória que outro lê.
eles compartilham um APIC (controlador de interrupção programável). É a memória mapeada no espaço de endereço físico e pode ser usada por um processador para controlar os outros, ativá-los ou desativá-los, enviar interrupções etc.
http://www.cheesecake.org/sac/smp.html é uma boa referência com um URL bobo.
fonte
A principal diferença entre um aplicativo único e um multiencadeado é que o primeiro possui uma pilha e o último possui um para cada encadeamento. O código é gerado de maneira um pouco diferente, pois o compilador assumirá que os registros de segmento de dados e pilha (ds e ss) não são iguais. Isso significa que a indireção através dos registros ebp e esp que padrão para o registro ss também não será padrão para ds (porque ds! = Ss). Por outro lado, a indireção através dos outros registradores que padrão para ds não será padrão para ss.
Os threads compartilham tudo o mais, incluindo áreas de dados e código. Eles também compartilham rotinas de lib, portanto, certifique-se de que sejam seguros para threads. Um procedimento que classifica uma área na RAM pode ser multiencadeado para acelerar as coisas. Os encadeamentos estarão acessando, comparando e ordenando dados na mesma área de memória física e executando o mesmo código, mas usando diferentes variáveis locais para controlar sua respectiva parte da classificação. É claro que isso ocorre porque os threads têm pilhas diferentes onde as variáveis locais estão contidas. Esse tipo de programação requer um ajuste cuidadoso do código para reduzir as colisões de dados entre os núcleos (em caches e RAM), o que resulta em um código mais rápido com dois ou mais threads do que com apenas um. Obviamente, um código não sintonizado geralmente será mais rápido com um processador do que com dois ou mais. Depurar é mais desafiador, porque o ponto de interrupção "int 3" padrão não será aplicável, pois você deseja interromper um segmento específico e não todos. Os pontos de interrupção do registro de depuração também não resolvem esse problema, a menos que você possa configurá-los no processador específico que está executando o encadeamento específico que deseja interromper.
Outro código multithread pode envolver diferentes threads sendo executados em diferentes partes do programa. Esse tipo de programação não requer o mesmo tipo de ajuste e, portanto, é muito mais fácil de aprender.
fonte
O que foi adicionado em toda arquitetura com capacidade de multiprocessamento em comparação com as variantes de processador único que vieram antes delas são instruções para sincronizar entre núcleos. Além disso, você tem instruções para lidar com a coerência do cache, buffers de liberação e operações semelhantes de baixo nível com as quais um sistema operacional precisa lidar. No caso de arquiteturas multithread simultâneas como IBM POWER6, IBM Cell, Sun Niagara e Intel "Hyperthreading", você também tende a ver novas instruções para priorizar entre threads (como definir prioridades e fornecer explicitamente o processador quando não há nada a fazer) .
Mas a semântica básica de thread único é a mesma, basta adicionar recursos extras para lidar com a sincronização e a comunicação com outros núcleos.
fonte