Pular para o conteúdo

Hardware e sistemas operacionais · parte 22 de 27

Pipeline, cache e paralelismo: como a CPU faz várias coisas ao mesmo tempo

Como a CPU acelera o processamento: pipeline, previsão de desvios, execução fora de ordem, cache L1/L2/L3, memória virtual, SIMD e multithreading.

avançadoPublicada em · 38 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

Pipeline, cache e paralelismo: como a CPU faz várias coisas ao mesmo tempo

Imagine uma linha de montagem de carros. Um carro leva, digamos, cinco estações para ficar pronto. Se a fábrica esperasse um carro terminar para começar o próximo, cada estação ficaria parada quatro quintos do tempo. A solução óbvia é começar um carro novo assim que o anterior sai da primeira estação. Depois de algumas horas, todas as estações trabalham ao mesmo tempo, em carros diferentes, e a fábrica entrega um carro por intervalo, apesar de cada carro levar cinco intervalos para ficar pronto. A CPU moderna faz exatamente esse truque, chamado pipeline, e acrescenta outros, para não deixar nenhuma de suas peças ociosa.

Esta nota junta várias ideias de desempenho que parecem independentes, mas têm a mesma origem: a CPU é muito mais rápida que a memória, e o programa raramente pede coisas na ordem mais conveniente para o hardware. Todo o esforço para ganhar velocidade consiste em dar um jeito de manter as peças ocupadas, sem mudar o resultado final.

1. Por que a CPU precisa de paralelismo

Se a CPU terminasse uma instrução antes de começar a próxima, o trabalho de cada uma se somaria. Um núcleo com ciclo de cinco etapas, cada uma de um ciclo de clock, levaria cinco ciclos por instrução. Com um clock de 3 GHz, isso dá 600 milhões de instruções por segundo, no máximo. Só que a maior parte das etapas usa uma peça diferente do chip. Enquanto a parte que busca instruções trabalha, a parte que executa contas fica parada, e o inverso também vale.

O paralelismo é a forma de aproveitar essas peças paradas. Ele pode ser de três tipos, e a seção seguinte mostra cada um: paralelismo entre instruções, dentro do mesmo núcleo, que vem do pipeline e da execução superescalar; paralelismo de dados, que processa vários valores com uma instrução, o SIMD; e paralelismo de tarefas, em que vários núcleos ou threads rodam programas diferentes ou partes do mesmo programa.

2. O pipeline: uma instrução por ciclo, em regime

Um pipeline divide a execução de uma instrução em estágios. O modelo clássico, ensinado em livros de arquitetura, tem cinco: busca (IF), decodificação (ID), execução (EX), acesso à memória (MEM) e escrita do resultado (WB). Cada estágio ocupa uma parte separada do chip, e entre um estágio e o seguinte há um registrador de separação que guarda o que a instrução já fez. Com isso, cinco instruções podem estar no chip ao mesmo tempo, cada uma num estágio diferente.

Vamos calcular o ganho. Sem pipeline, quatro instruções de cinco estágios levam 4 × 5 = 20 ciclos. Com pipeline, a primeira termina no ciclo 5, e cada uma das outras termina um ciclo depois da anterior. O total é 5 + 3 = 8 ciclos. O ganho aparece porque, depois de encher o pipeline, uma instrução sai por ciclo, mesmo que cada uma continue levando cinco ciclos para atravessar o chip. A latência de uma instrução não muda. Muda a vazão, que é a quantidade que sai por segundo.

A figura abaixo mostra as quatro instruções avançando pelas colunas, cada uma uma linha abaixo da anterior. Nas colunas do meio já há três instruções no chip ao mesmo tempo.

Pipeline ideal de cinco estágios com quatro instruções: cada uma avança um estágio por ciclo, e a quarta começa no ciclo 4 sem esperar as anteriores terminarem
figura 1 · Pipeline ideal: quatro instruções avançam um estágio por ciclo

Leia a figura da esquerda para a direita, e de cima para baixo. A primeira linha é a instrução I1, que ocupa os estágios de IF a WB nos ciclos 1 a 5. A segunda linha começa uma coluna depois, porque a busca dela só pode acontecer quando a primeira saiu da busca. Esse atraso é o que cria a forma de escada, e ele é a assinatura visual de um pipeline sem problemas.

Conta com um lote de instruções

Vale fazer a conta com um lote maior, porque é aí que a vantagem aparece de verdade. Dez instruções de cinco estágios levam 50 ciclos sem pipeline. Com pipeline, a primeira sai no ciclo 5 e cada uma das nove seguintes sai um ciclo depois da anterior, então o total é 5 + 9 = 14 ciclos. A aceleração é 50 / 14, cerca de 3,6 vezes, e não 5, porque o pipeline leva alguns ciclos para encher no começo e outros para esvaziar no fim. Quanto maior o lote, mais a aceleração se aproxima do número de estágios. Num programa real, os perigos da seção seguinte tiram parte desse ganho, por isso o resultado observado costuma ficar abaixo do ideal.

Pipelines reais têm mais estágios

O modelo de cinco estágios é didático. Pipelines de chips reais são bem mais longos. O P6 do Pentium Pro, de 1995, tem 12 estágios, segundo a tabela de pipelines da Wikipedia. O Core 2, da família Conroe, tem 14. O Pentium 4 Prescott, de 2004, chegou a 31 estágios no pipeline de inteiros, segundo publicações da época, e essa contagem deixa de fora alguns estágios iniciais de decodificação. O SiFive P550, um núcleo RISC-V de três vias, tem 13. A figura compara essas profundidades.

Profundidade de pipeline de cinco núcleos, de 5 estágios no modelo didático a 31 no Pentium 4 Prescott, em barras horizontais
figura 2 · Profundidade de pipeline de alguns núcleos, em número de estágios

Pipelines mais longos permitem clock mais alto, mas pagam mais a cada desvio errado, como vimos na seção de perigos. O Prescott ilustra esse trade-off: clock alto, penalidade alta. A geração seguinte da Intel, o Core, voltou a usar um pipeline bem mais curto, de 14 estágios. Contagens de estágio não são comparáveis entre fabricantes, porque cada um define o que conta como estágio.

3. Quando o pipeline para: os perigos

O pipeline funciona enquanto cada instrução pode seguir sem depender do resultado da anterior. Quando essa condição falha, aparece um perigo, em inglês hazard. Existem três tipos clássicos. O perigo estrutural acontece quando duas instruções pedem a mesma peça no mesmo ciclo, por exemplo a mesma unidade de memória. O perigo de dados acontece quando uma instrução precisa de um valor que ainda não foi calculado pela anterior. O perigo de controle acontece quando o próximo endereço depende de um desvio ainda não resolvido.

O perigo de dados mais famoso é o que segue um LOAD. A instrução que lê da memória só tem o valor no fim do estágio MEM, e se a próxima instrução precisar dele logo na EX, há um problema. A solução de hardware mais comum é o encaminhamento, em inglês forwarding, que manda o valor direto da saída de um estágio para a entrada de outro, sem esperar o registrador. Mesmo com encaminhamento, o caso de LOAD seguido de uso imediato exige uma parada de um ciclo, chamada de bolha.

A figura abaixo mostra dois perigos juntos. No topo, o LOAD seguido de ADD: a bolha aparece porque o ADD precisa do valor que só fica pronto depois do MEM do LOAD. Abaixo, um BEQ, que é um desvio condicional, é resolvido no estágio EX, e nesse meio tempo a CPU já buscou e decodificou instruções seguintes, que estão erradas se o desvio for tomado. Elas são descartadas.

Dois perigos do pipeline: um ciclo de espera entre LOAD e ADD, e uma busca após desvio com previsão errada, descartada antes de seguir o alvo
figura 3 · Perigos no pipeline: parada por dado e descarte por desvio errado

O preço do desvio é o ponto mais importante para entender o que vem a seguir. Na figura, o desvio só é resolvido no ciclo 3, então a CPU buscou duas instruções a mais, que foram descartadas. Um pipeline mais longo, com mais estágios, tende a resolver os desvios mais tarde, e a penalidade de um erro de previsão aumenta. Essa é uma das razões pelas quais os projetistas tentam prever os desvios com precisão, e não só esperar.

Quanto custa errar a previsão

Para tornar a penalidade concreta, considere um laço de 1.000 voltas com um desvio no fim. Se a previsão erra 10% das vezes e cada erro descarta 2 instruções já buscadas, a penalidade média é 0,10 × 2 = 0,2 ciclo por desvio, ou seja, 200 ciclos perdidos em 1.000 desvios. Um erro raro pode custar mais que uma bolha frequente, porque o erro de previsão desperdiça vários ciclos de trabalho, enquanto a bolha de um LOAD costuma durar um ciclo só.

4. Previsão de desvios

A previsão de desvios é um circuito que tenta adivinhar, antes de a condição ser calculada, se o desvio será tomado. O método mais simples observa o comportamento passado: se o desvio foi tomado da última vez, a CPU aposta que será tomado de novo. Métodos mais sofisticados usam tabelas que guardam o histórico de muitos desvios, e padrões de repetição, como o desvio de um laço for, que é tomado 99 vezes e não tomado na centésima.

Quando a previsão acerta, a CPU ganha tempo, porque não parou. Quando erra, ela precisa descartar o trabalho especulativo e recomeçar pelo caminho correto. Por isso, a precisão da previsão pesa tanto no desempenho de programas com muitos condicionais, e por isso também ela é um dos pontos em que apareceram as vulnerabilidades de Spectre, tratadas em Limites e segurança. A previsão deixa rastros, e esses rastros podem ser lidos por outro programa.

Contador de dois bits, passo a passo

O método de “última vez” erra duas vezes em cada laço: na saída e na entrada seguinte. Um contador saturante de dois bits resolve isso com um pouco mais de memória. Ele tem quatro valores, de 00 (fortemente não tomado) a 11 (fortemente tomado). Cada desvio tomado soma 1, cada não tomado subtrai 1, e o contador não passa dos extremos. A previsão é “tomado” quando o valor é 10 ou 11. A figura mostra as transições.

Contador saturante de dois bits com quatro estados, de 00 a 11: um desvio tomado sobe o contador, um não tomado desce, e a previsão é tomado nos estados 10 e 11
figura 4 · Contador saturante de dois bits do previsor de desvios

Considere um laço de 100 voltas, em que o desvio do fim é tomado 99 vezes e não tomado na última. O contador começa em 11. As 99 voltas o mantêm em 11, e a saída, não tomada, o leva a 10. Quando o laço roda de novo, o estado 10 ainda prevê “tomado”, então só a saída erra: uma falha por laço, 99% de acerto. Com um contador de um bit, a saída mudaria o estado para “não tomado” e a entrada seguinte erraria de novo, ou seja, duas falhas por laço.

Previsores modernos combinam o histórico recente de desvios com o endereço do desvio, o que capta padrões mais longos. Não tenho uma medição pública da taxa de acerto de um modelo específico, então a conta fica no exemplo do laço.

5. Execução fora de ordem e superescalar

O pipeline clássico é em ordem: as instruções saem na mesma ordem em que entraram. Quando a instrução da frente espera um dado lento, como uma leitura de memória, toda a fila espera junto, mesmo que a instrução de trás pudesse seguir. A execução fora de ordem resolve isso com uma fila de instruções prontas. A CPU escolhe as que já têm seus operandos disponíveis, executa, e guarda o resultado num buffer de reordenação, que devolve as instruções para a ordem original no final. O resultado visível para o programa continua igual, só que o trabalho acontece em outra ordem.

A execução superescalar dá outro passo: a CPU lança mais de uma instrução por ciclo, em vias diferentes do chip. O Pentium original, como a nota de história registra, já fazia duas instruções por ciclo em muitos casos. Um núcleo moderno tem várias vias de execução, e a eficácia depende de quantas instruções independentes estão disponíveis em cada momento. A figura abaixo compara três formas de fazer mais trabalho por ciclo.

Escalar faz uma soma por instrução, SIMD faz quatro somas numa instrução vetorial, e o superescalar executa duas vias independentes no mesmo ciclo
figura 5 · Três formas de fazer mais trabalho por ciclo: escalar, SIMD e superescalar

A coluna do meio mostra o SIMD, que é o paralelismo de dados. Uma única instrução vetorial aplica a mesma operação a vários valores ao mesmo tempo, nas faixas do registrador vetorial. Ele é ótimo para processamento de imagem, áudio e aprendizado de máquina, onde a mesma conta se repete sobre milhões de valores. Ele não ajuda quando cada valor depende do anterior, e nesse caso o trabalho continua em série.

Renomeação de registradores, passo a passo

O programa usa poucos nomes de registrador, e isso cria dependências que não existem de fato. Considere quatro instruções: I1 escreve em R1, I2 lê R1, I3 escreve de novo em R1, e I4 lê R1 para pegar o valor de I3. A dependência entre I1 e I2 é verdadeira, porque I2 precisa do resultado de I1. Já o segundo R1 de I3 não tem relação com o primeiro: é só reuso de nome. Sem renomeação, I3 teria de esperar I2 terminar apenas por causa do nome. A renomeação mantém uma tabela que aponta cada nome lógico para um registrador físico. Quando I3 chega, a tabela passa a apontar R1 para um físico novo, p12, enquanto I1 continua escrevendo em p10. Assim I3 pode executar antes de I2. A figura mostra o antes e o depois.

Quatro instruções antes e depois da renomeação: o segundo R1 vira p12, a dependência falsa some e I3 pode executar antes de I2
figura 6 · Renomeação de registradores: o segundo R1 ganha outro nome e a dependência falsa

Existem mais registradores físicos que lógicos, e a tabela precisa ser desfeita quando um desvio errado descarta instruções já renomeadas. Essa reversão é parte do que torna o front-end e o back-end tão delicados de projetar.

Estações de reserva e buffer de reordenação

Depois de renomeada, cada micro-operação espera numa estação de reserva até seus operandos ficarem prontos, e então é despachada para uma unidade de execução. Ao mesmo tempo, ela ganha uma entrada no buffer de reordenação, na ordem do programa. O resultado é gravado quando a execução termina, mas o estado visível só é confirmado na retirada, que segue a ordem da fila. A figura mostra cinco instruções. I1 é um LOAD que espera a memória, I2 depende dele, I3 e I4 já terminaram, e I5 espera I4.

Buffer de reordenação com cinco instruções: I3 e I4 já terminaram, I1 espera a memória, e a retirada confirma as instruções na ordem do programa
figura 7 · Buffer de reordenação: resultados saem fora de ordem e são confirmados na ordem

Por que a espera é tolerada? Porque I3 e I4 ficam guardadas, prontas, até que I1 e I2 sejam confirmadas. Se I1 causar uma exceção, I3 e I4 são descartadas sem ter alterado o estado visível, e é isso que mantém o programa correto mesmo com a execução bagunçada por dentro. O tamanho do buffer limita quanto trabalho o núcleo consegue manter em voo. Segundo a AMD, o Zen 5 tem 448 entradas nesse buffer, contra 320 no Zen 4 (material do Hot Chips 2024).

SIMD com um exemplo vetorial

Suponha somar dois vetores de oito números de ponto flutuante de 32 bits. Em código escalar, um laço faz oito somas, uma por instrução. Com AVX, um registrador de 256 bits guarda oito valores de 32 bits (256 / 32 = 8), e a instrução VADDPS soma as oito pistas de uma vez. O código em C fica assim: for (int i = 0; i < 8; i++) c[i] = a[i] + b[i];. Com as opções de otimização adequadas, o compilador pode trocar esse laço por instruções vetoriais. A figura compara as duas formas.

Somar oito pares de valores com oito instruções escalares contra uma única instrução vetorial que usa oito pistas de 32 bits
figura 8 · Escalar faz uma soma por instrução; uma instrução vetorial faz oito de uma vez

A figura não mostra o custo de carregar os vetores da memória, que também conta. SIMD ajuda só quando o trabalho cabe nas pistas. Num laço em que c[i] depende de c[i-1], a dependência impede a vetorização, e o ganho some.

6. Hierarquia de memória e cache

Agora chegamos ao gargalo mais importante. A CPU consegue executar uma soma em um ciclo, mas buscar um valor da RAM pode levar centenas de ciclos. Se o programa precisasse esperar isso toda vez, a CPU passaria quase todo o tempo parada. A solução é uma hierarquia de memórias: cada nível é maior e mais lento que o anterior, e o conjunto parece, para o programa, uma única memória muito rápida.

A figura abaixo mostra os níveis, do registrador até o SSD. Os níveis mais próximos da CPU são pequenos e rápidos. Os mais distantes são grandes e lentos. O acesso ao dado começa pelo nível mais alto e desce até encontrá-lo.

Hierarquia de memória de registradores a SSD: o dado é achado no L2, que acende como acerto, e os níveis acima aparecem como falha
figura 9 · Hierarquia de memória: a CPU procura do nível mais rápido ao mais lento

A figura mostra o caso em que o dado estava no L2. Os níveis de registrador e L1 foram consultados e não tinham o valor, então são marcados como falha. A busca parou no L2, que atendeu o pedido. Se o dado não estivesse em nenhum nível de cache, a CPU precisaria ir à RAM, e isso custa muito mais.

Por que a hierarquia funciona? Por causa da localidade de referência. Um programa tende a usar os mesmos dados repetidamente, que é a localidade temporal, e dados vizinhos aos que acabou de usar, que é a localidade espacial. Quando a CPU lê um valor, ela não traz só aquele byte: traz uma linha de cache inteira, em geral de 64 bytes, que contém os vizinhos. Se o programa percorrer um vetor em ordem, a maioria dos acessos cai na linha que já está no cache. Se percorrer aleatoriamente uma estrutura gigante, quase todo acesso falha.

O cache é organizado em níveis. O L1 é o menor e mais rápido, costuma ser separado para instruções e dados, e pertence a cada núcleo. O L2 é maior e um pouco mais lento, e em muitos chips também é de cada núcleo. O L3 é grande e compartilhado entre os núcleos do mesmo bloco. Os valores de capacidade e latência mudam de um chip para outro. A nota de gerações traz as figuras de anatomia de cinco chips reais, com os números confirmados para cada um. O que importa aqui é a proporção: cada salto de nível multiplica o tamanho e o custo de acesso.

Endereço dividido em tag, índice e offset

A cache não guarda endereços inteiros. Ela divide cada endereço em três campos. O offset diz qual byte, dentro da linha, interessa. O índice diz em qual conjunto a linha deve estar. A tag guarda o que sobra, para confirmar que a linha guardada é mesmo do endereço procurado, porque muitos endereços diferentes caem no mesmo conjunto.

Como exemplo didático, use uma cache com linhas de 16 bytes (offset de 4 bits) e 16 conjuntos (índice de 4 bits). Num endereço de 16 bits, sobram 8 bits de tag. O endereço 0x3A5C, em binário, é 0011 1010 0101 1100. Separando os bits, a tag são os 8 primeiros (0011 1010 = 0x3A), o índice são os 4 seguintes (0101 = 5) e o offset são os 4 últimos (1100 = 12). A CPU vai ao conjunto 5, compara a tag 0x3A com as tags guardadas ali e, se houver igualdade, entrega o byte 12 da linha. O programa não vê nada disso: ele só enxerga o endereço.

Endereço de 16 bits 0x3A5C dividido em tag 0x3A, índice 5 e offset 12, com o índice escolhendo o conjunto da cache
figura 10 · Endereço de 16 bits dividido em tag, índice e offset para uma cache de exemplo

Caches reais usam linhas de 64 ou 128 bytes, o que muda o tamanho do offset, e conjuntos muito maiores, mas a divisão é a mesma.

Associatividade e substituição

Se cada índice tivesse uma única linha possível, dois endereços com o mesmo índice se expulsariam mesmo com o resto da cache vazio. Por isso as caches são associativas por conjunto: cada conjunto tem várias vias. A figura mostra uma cache de duas vias, em que o conjunto 5 guarda duas linhas.

Cache de duas vias: o índice 5 escolhe o conjunto, a tag 0x3A bate com a via 1, e o acerto entrega o byte 12 da linha
figura 11 · Acerto numa cache de duas vias: o índice escolhe o conjunto e as tags decidem

Quando um conjunto está cheio e chega uma linha nova, a cache escolhe uma vítima. A política mais citada é a LRU, que expulsa a linha menos recentemente usada. O LRU exato fica caro com muitas vias, então os projetos reais usam aproximações. As tags das duas vias são comparadas em paralelo no hardware, e é por isso que a associatividade não custa um passo extra por comparação.

Tempo médio de acesso

Para comparar níveis de cache, a conta usual é o tempo médio de acesso. Use os números de latência em ciclos que a Chips and Cheese mediu para o núcleo Lion Cove: L1 de 48 KB com 4 ciclos, L2 de 3 MB com 17 ciclos, e uma L3 de 12 MB com 51 ciclos, numa configuração medida pela publicação. Para a RAM, use 150 ciclos como valor ilustrativo, porque a medição que fiz no Mac (abaixo) dá cerca de 137 a 146 ns, que não se traduz direto em ciclos sem saber a frequência do núcleo.

Suponha que a L1 erre em 5% dos acessos, que a L2 erre em 20% dos que chegam a ela, e que a L3 erre em 30% dos que chegam a ela. O tempo médio fica:

T = 4 + 0,05 × (17 + 0,20 × (51 + 0,30 × 150)) T = 4 + 0,05 × (17 + 0,20 × 96) T = 4 + 0,05 × 36,2 T = 5,81 ciclos

O acesso médio custa 5,81 ciclos, quase 1,5 vez a L1 sozinha. A taxa de erro da L1, de 5%, responde por 0,05 × 36,2 = 1,81 ciclo do total. Reduzir o erro da L1 para 2% baixa a média para 4 + 0,02 × 36,2 = 4,72 ciclos, e é por isso que engenheiros de cache se preocupam tanto com a taxa de acerto do primeiro nível.

Coerência entre núcleos (MESI)

Quando dois núcleos têm cópias da mesma linha, um protocolo de coerência decide quem pode ler e quem pode escrever. O MESI dá a cada linha um de quatro estados: M, modificada, única e diferente da RAM; E, exclusiva, única e igual à RAM; S, compartilhada, com cópias iguais em mais de um núcleo; e I, inválida. Para escrever, um núcleo precisa ter a linha em M ou E, e escrever numa linha compartilhada obriga a invalidar as outras cópias antes.

Os quatro estados do protocolo MESI, M, E, S e I, com as transições causadas por leituras e escritas locais e de outros núcleos
figura 12 · Protocolo MESI: os quatro estados de uma linha de cache em cada núcleo

O MESI é um modelo. Protocolos reais acrescentam estados, como o O do MOESI, e cada fabricante tem variações, então a figura descreve a lógica geral e não um chip específico.

Um caso prático é o falso compartilhamento. Duas variáveis independentes que caem na mesma linha fazem os núcleos se invalidarem mutuamente, mesmo sem dado comum. O remédio é separar as variáveis em linhas diferentes. O tamanho da linha importa aqui: pelo sysctl deste Mac, as linhas são de 128 bytes, e o conjunto de variáveis que colidem é maior do que numa máquina de 64 bytes.

Prefetch

O hardware observa padrões de acesso e carrega linhas antes que o programa as peça. Um acesso sequencial dispara o prefetch, e um acesso espalhado não. Por isso a medição da seção 13 mostra diferença tão grande: o mesmo volume de dados custa muito menos quando o padrão é previsível. Não tenho medição pública do prefetch de um modelo específico, então o efeito é descrito aqui sem números.

Um detalhe que costuma surpreender é o custo de escrita. Quando um núcleo altera um dado, outros núcleos que têm uma cópia dele precisam saber que ela ficou velha. Os protocolos de coerência de cache resolvem isso, e eles custam tempo. Um programa que muda o mesmo endereço em muitos núcleos ao mesmo tempo pode ser mais lento do que o mesmo programa rodando em um núcleo só.

7. Memória virtual e MMU

Os programas não enxergam a RAM física. Eles trabalham com endereços virtuais, que o sistema operacional mapeia para endereços físicos. A peça que faz essa tradução é a MMU, a unidade de gerenciamento de memória, que fica dentro da CPU ou logo ao lado dela. Essa separação dá três benefícios: cada programa tem seu próprio espaço de endereços, programas não podem ler a memória uns dos outros sem permissão, e o sistema consegue usar disco como memória de reserva quando a RAM acaba.

O mapeamento é feito por páginas, blocos de tamanho fixo, em geral de 4 KB em sistemas comuns. A tabela de páginas, guardada na RAM, diz para cada página virtual em que página física ela está. Consultar essa tabela a cada acesso seria lento, então a CPU guarda as traduções recentes numa memória pequena e rápida chamada TLB. Se a tradução está no TLB, o acesso é quase imediato. Se não está, a MMU percorre a tabela, o que custa mais, e depois guarda a nova entrada no TLB.

A figura abaixo desenha esse caminho. O programa emite um endereço virtual, a MMU consulta a TLB e, se necessário, a tabela de páginas, e só então sai o endereço físico para a RAM. Se a página não estiver na RAM, a MMU gera uma falha de página, que o sistema operacional trata.

Tradução de endereço virtual pela MMU: a TLB é consultada, a tabela de páginas fica na RAM, e uma página ausente gera falha que o sistema operacional trata
figura 13 · Memória virtual: a MMU traduz o endereço antes de chegar à RAM

Uma falha de página pode ser perfeitamente normal, e é o mecanismo que faz a memória virtual funcionar. Se o sistema carrega a página do disco e o programa segue, o usuário nem percebe. Se o acesso é inválido, por exemplo um ponteiro para um endereço que não pertence ao processo, o sistema encerra o programa. É por isso que um erro de segmentação costuma ser a falha de um acesso proibido, e não uma falha de hardware.

Tabela de páginas de quatro níveis

Em x86-64 com páginas de 4 KB, um endereço virtual de 48 bits se divide em quatro índices de 9 bits e um deslocamento de 12 bits (9 + 9 + 9 + 9 + 12 = 48). A tabela é organizada em quatro níveis, PML4, PDPT, PD e PT, e cada nível é uma página de 4 KB com 512 entradas de 8 bytes (512 × 8 = 4.096). A MMU percorre a árvore a partir do PML4, e cada passo é um acesso à RAM. Uma falha de TLB pode custar, assim, quatro acessos extras à memória.

A figura mostra o caminho com índices ilustrativos. Se a TLB acerta, o núcleo recebe o endereço físico sem tocar na RAM. Se erra, a MMU percorre os quatro níveis e depois grava a tradução na TLB para o próximo acesso à mesma página.

Tradução de endereço virtual: se a TLB acerta, o núcleo recebe o endereço físico; se erra, a MMU percorre quatro níveis da tabela de páginas na RAM
figura 14 · Tradução de endereço virtual: a TLB acerta, ou a MMU percorre quatro níveis da t

A TLB evita essa conta na maior parte dos acessos, porque programas reutilizam páginas com frequência. Páginas grandes, de 2 MB ou 1 GB, reduzem o número de entradas necessárias para cobrir a mesma memória, e é por isso que sistemas que usam muita memória as adotam.

8. Interrupções: o chip que precisa ser avisado

Até agora, a CPU parece executar sozinha. Na prática, ela precisa reagir a eventos externos: uma tecla pressionada, um pacote de rede que chegou, um disco que terminou uma leitura. Para isso existe a interrupção. Um dispositivo ou um controlador de interrupções levanta uma linha de sinal, e a CPU, ao final da instrução que está executando, salva o contador de programa e os registradores importantes, salta para o tratador da interrupção e, quando termina, restaura o estado e continua de onde parou.

A figura abaixo mostra a sequência de uma tecla pressionada. A CPU não fica consultando o teclado o tempo todo, o que desperdiçaria ciclos. O controlador a avisa só quando há um evento, e o programa principal segue sem perceber, exceto por um intervalo curto.

Interrupção de teclado: o controlador avisa a CPU, que salva o estado, atende o tratador e devolve o controle ao programa onde ele parou
figura 15 · Interrupção: o dispositivo avisa a CPU, que salva o estado e atende

Interrupções são a razão pela qual um computador consegue atender teclado, rede e disco sem parar o programa principal a cada instante. Elas também são uma fonte de latência: um tratador lento atrasa tudo o que vem depois dele. Quando um sistema recebe muitos eventos por segundo, como uma placa de rede de alta velocidade, o sistema pode agrupar interrupções para reduzir o custo de cada uma.

9. SMT, núcleos P e E e a visão de vários núcleos

Um núcleo tem muitas unidades de execução, e nem sempre um único fluxo de instruções consegue mantê-las ocupadas. O SMT, chamado de Hyper-Threading pela Intel, mantém dois contextos de execução (threads) ativos num mesmo núcleo, que dividem as unidades. Quando um thread espera uma memória, o outro usa as unidades livres. O ganho varia muito de programa para programa e nunca chega ao dobro, porque os dois threads disputam as mesmas peças. Não encontrei uma medição geral confiável para citar aqui. A figura abaixo mostra os dois tipos de núcleo lado a lado.

Dois núcleos P com dois threads cada, quatro núcleos E com uma thread e uma L3 compartilhada, mostrando que threads SMT dividem as unidades
figura 16 · Núcleos de desempenho, núcleos de eficiência e threads SMT no mesmo chip

Os núcleos de desempenho, marcados como P, são grandes, têm execução fora de ordem ampla e são feitos para frequências altas. Os núcleos de eficiência, os E, são menores e gastam bem menos energia, e são feitos para tarefas leves ou em segundo plano. Essa combinação, chamada big.LITTLE no mundo ARM e de P e E na Intel e na Apple, existe porque a maior parte do tempo de um computador é ocupada por tarefas pequenas, e usar um núcleo grande para elas desperdiça bateria. O sistema operacional decide qual núcleo recebe cada tarefa.

Os núcleos compartilham uma cache de nível 3 e um barramento para a memória. A figura mostra a cache compartilhada como o ponto de encontro, e a nota de limites mostra por que o número de núcleos não cresce sem limite. Mais núcleos disputam a mesma memória, e a eficiência de um programa paralelo cai quando ele precisa de muita comunicação entre tarefas.

Como o sistema enxerga os núcleos

Nem sempre o número de núcleos que o programa vê é o número de núcleos físicos. Em um Mac com chip Apple, o sistema separa núcleos por cluster de desempenho e de eficiência. Estes são os valores que o sysctl deu nesta máquina, um Apple M1 Max com 10 núcleos (8 P e 2 E), 32 GB de memória e 128 bytes por linha de cache:

$ sysctl -n hw.physicalcpu hw.logicalcpu hw.perflevel0.physicalcpu hw.perflevel1.physicalcpu
10
10
8
2

O total de 10 núcleos físicos e de 10 lógicos mostra que este chip não usa SMT. Os níveis perflevel0 (P) e perflevel1 (E) dão a divisão entre os clusters, e é ali que estão as caches de cada cluster: hw.perflevel0.l2cachesize é 12 MB e hw.perflevel1.l2cachesize é 4 MB. O valor global hw.l1dcachesize deu 64 KB, mas não consegui confirmar a qual cluster esse número se refere, então para o L1 vale consultar os valores por nível.

No Linux, a leitura equivalente é feita pelo lscpu, que lista sockets, núcleos por socket, threads por núcleo e o total de CPUs lógicas. Não rodei lscpu nesta revisão, porque a máquina de teste é um Mac. A figura mostra a forma das duas leituras com a estrutura típica de um x86 com SMT: a CPU 0 e a CPU 8 são threads irmãs do mesmo núcleo, e o nome da CPU é um número que o kernel atribui, não uma posição física.

Duas leituras do número de núcleos: o sysctl do macOS num M1 Max, com clusters P e E, e o lscpu do Linux num x86 com SMT, com threads irmãs
figura 17 · O sistema operacional enxerga núcleos e threads de formas diferentes

Essa diferença explica por que um programa que pergunta “quantos núcleos eu tenho” pode receber respostas diferentes, conforme o que ele conta. Para dimensionar um pool de threads, geralmente importa o número de núcleos de desempenho, e não o total, porque threads em núcleos E terminam mais devagar.

10. Chiplets e a organização do pacote

Nos últimos anos, a forma de montar uma CPU mudou. Em vez de um único bloco de silício com tudo, muitos chips grandes são feitos com processos diferentes e unidos num pacote, cada um chamado chiplet, ou die. Os núcleos ficam num die, a parte de entrada e saída e o controlador de memória ficam em outro, e um barramento interno, no caso da AMD o Infinity Fabric, liga os dois. A nota de gerações mostra o desenho completo com a linha Ryzen.

Lei de Amdahl com números

Se uma fração p do trabalho pode ser paralelizada e o resto é sequencial, o ganho com n núcleos é S(n) = 1 / ((1 - p) + p / n). Esse ganho nunca passa de 1 / (1 - p), não importa quantos núcleos existam. Com p = 0,9 e oito núcleos, S = 1 / (0,1 + 0,9 / 8) = 1 / 0,2125 ≈ 4,7. Com 64 núcleos, S = 1 / (0,1 + 0,9 / 64) ≈ 8,8, e o limite é 10. Oito vezes mais núcleos rendem menos que o dobro de ganho. A figura mostra três frações paralelas.

Curvas da lei de Amdahl: o ganho com n núcleos para p de 50, 90 e 95 por cento, com o limite de 20 vezes para p de 95 por cento
figura 18 · Lei de Amdahl: ganho com n núcleos para três frações paralelas

A curva de p = 50% quase não sobe depois de poucos núcleos, porque metade do trabalho sempre será sequencial. Por isso a primeira pergunta antes de pedir mais threads é qual fração do programa realmente roda em paralelo.

11. GPU e CPU em contraste breve

A GPU, a placa de vídeo, é uma máquina de paralelismo de dados. Ela tem milhares de núcleos pequenos que executam a mesma instrução sobre muitos valores, o que combina com gráficos e com a maior parte do aprendizado de máquina. Uma CPU tem poucos núcleos grandes, cada um com cache, previsão de desvios e execução fora de ordem, o que serve para tarefas com muitas decisões e dependências. Pense em uma CPU como uma equipe pequena de especialistas que resolvem problemas diferentes um atrás do outro, e numa GPU como um galpão de operários idênticos fazendo a mesma tarefa sobre milhares de peças. Nenhum dos dois substitui o outro, e a maioria dos computadores tem os dois.

12. Erros comuns e boas práticas

O erro mais frequente é achar que mais núcleos sempre aceleram um programa. Se o programa tem uma parte sequencial grande, o ganho é limitado por essa parte, e a lei de Amdahl quantifica isso. Antes de pedir mais threads, descubra qual parte do trabalho não pode ser paralelizada.

O segundo erro é ignorar a localidade. Um programa que percorre uma lista de ponteiros espalhados pela memória sofre muitos erros de cache, por mais que a CPU seja potente. Reorganizar os dados em vetores contíguos, quando possível, costuma dar um ganho grande sem mudar o algoritmo.

O terceiro erro é tratar o pipeline como caixa-preta. Quando um laço fica lento sem motivo aparente, o problema costuma estar num desvio imprevisível, num acesso à memória ruim ou numa dependência entre instruções. Um profiler ajuda a ver onde o tempo vai, e a nota Carga da CPU no Mac mostra como olhar a carga do sistema antes de culpar o código.

O quarto erro, de novo, é otimizar sem medir. A intuição sobre desempenho de CPU erra com frequência, porque o custo real depende de detalhes do chip. Meça com ferramentas de perfil e confirme cada ganho.

13. Medindo o efeito da cache

Os números de latência acima são de uma fonte. Para sentir o efeito na própria máquina, escrevi um programa em C que mede dois padrões de acesso em quatro tamanhos de memória. O padrão sequencial soma uma palavra por linha de cache, em ordem. O padrão espalhado percorre uma permutação cíclica aleatória das linhas, em que cada acesso depende do anterior (pointer chasing). Como o acesso seguinte precisa do valor do atual, o tempo por passo é a latência real do nível de memória, sem o processador adiantar trabalho.

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <time.h>

#define STRIDE 128          /* bytes por linha usada no teste */
#define CHASE_STEPS 10000000UL
#define SEQ_TOTAL 20000000UL

static double now(void) {
    struct timespec t;
    clock_gettime(CLOCK_MONOTONIC, &t);
    return t.tv_sec + t.tv_nsec * 1e-9;
}

static uint64_t rng_state = 88172645463325252ULL;
static uint64_t rng(void) {
    rng_state ^= rng_state << 13;
    rng_state ^= rng_state >> 7;
    rng_state ^= rng_state << 17;
    return rng_state;
}

volatile uint64_t sink;

int main(void) {
    size_t sizes_kb[] = {16, 256, 4096, 65536};
    size_t nsizes = sizeof(sizes_kb) / sizeof(sizes_kb[0]);

    printf("%-12s %-22s %-22s\n", "tamanho", "sequencial (ns/linha)", "espalhado (ns/acesso)");
    for (size_t k = 0; k < nsizes; k++) {
        size_t bytes = sizes_kb[k] * 1024;
        size_t n = bytes / STRIDE;
        char *mem = aligned_alloc(4096, bytes);
        memset(mem, 1, bytes);

        /* (a) sequencial */
        size_t passes = SEQ_TOTAL / n;
        if (passes < 1) passes = 1;
        double t0 = now();
        uint64_t acc = 0;
        for (size_t p = 0; p < passes; p++)
            for (size_t i = 0; i < n; i++)
                acc += *(uint64_t *)(mem + i * STRIDE);
        double t1 = now();
        sink = acc;
        double seq_ns = (t1 - t0) * 1e9 / (double)(passes * n);

        /* (b) espalhado: permutação cíclica (Sattolo) */
        size_t *perm = malloc(n * sizeof(size_t));
        for (size_t i = 0; i < n; i++) perm[i] = i;
        for (size_t i = n - 1; i > 0; i--) {
            size_t j = rng() % i;
            size_t tmp = perm[i]; perm[i] = perm[j]; perm[j] = tmp;
        }
        for (size_t i = 0; i < n; i++)
            *(size_t *)(mem + perm[i] * STRIDE) = perm[(i + 1) % n];

        size_t cur = perm[0];
        t0 = now();
        for (size_t s = 0; s < CHASE_STEPS; s++)
            cur = *(size_t *)(mem + cur * STRIDE);
        t1 = now();
        sink = cur;
        double chase_ns = (t1 - t0) * 1e9 / (double)CHASE_STEPS;

        printf("%4zu KB       %-22.3f %-22.3f\n", sizes_kb[k], seq_ns, chase_ns);
        free(perm);
        free(mem);
    }
    return 0;
}

Compilei com clang -O2 -o cache_bench cache_bench.c e rodei duas vezes em sequência. A máquina é um Apple M1 Max com 10 núcleos (8 P e 2 E), 32 GB de memória e linha de cache de 128 bytes, segundo o sysctl. O load average estava alto durante a medição, entre 9 e 14 em 10 núcleos, porque outros processos rodavam. Por isso os valores variam de uma rodada para a outra, e a tabela mostra as duas. A saída real foi esta:

TamanhoSequencial, rodada 1 (ns/linha)Sequencial, rodada 2 (ns/linha)Espalhado, rodada 1 (ns/acesso)Espalhado, rodada 2 (ns/acesso)
16 KB0,2370,2072,0562,059
256 KB0,6430,6448,2586,848
4 MB0,7000,75616,27911,983
64 MB4,8923,888146,274136,840

A leitura da tabela é esta. Com 16 KB, a memória cabe na L1 e o acesso espalhado custa cerca de 2 ns por passo. O sequencial custa uns 0,2 ns por linha, porque o processador sobrepõe vários acessos independentes; o espalhado não pode fazer isso, já que cada acesso depende do anterior. Com 256 KB e 4 MB, o espalhado sobe para 7 a 16 ns, faixa de L2 e de cache compartilhada, e o sequencial quase não muda, porque o prefetch acompanha o padrão. Com 64 MB, a memória não cabe nas caches do cluster, e o espalhado chega a cerca de 140 ns, enquanto o sequencial custa algo como 4 ns por linha, porque o prefetch e a largura de banda entram em ação. A diferença de 30 a 40 vezes entre os dois padrões em 64 MB é o efeito que a seção de prefetch descreve.

Esta medição não separa os níveis com precisão. O salto entre 256 KB e 4 MB mistura latência de cache com custo de TLB, e a variação entre rodadas é de até 20%. Para afirmar uma latência de nível de cache com segurança, a medição precisa ser repetida em máquina sem carga e comparada com fonte de microarquitetura. O que ela mostra com segurança é a ordem de grandeza e a proporção entre os padrões.

Exercícios

  1. Calcule quantos ciclos levam dez instruções de cinco estágios sem pipeline e com pipeline. Qual é a aceleração aproximada e por que ela não chega a 5 vezes?
  2. Escreva um pequeno programa em que a previsão de desvios acerte quase sempre, e outro em que erre quase sempre. Explique a diferença de tempo com base no que foi descartado.
  3. Estime, com base na figura da hierarquia, quantas vezes um acesso ao nível L1 pode ser mais rápido que um acesso à RAM. Diga por que essa estimativa é aproximada e quais medições faria para confirmar.

14. Exercícios resolvidos

Respostas dos exercícios da seção de exercícios, com as contas feitas.

  1. Dez instruções de cinco estágios levam 50 ciclos sem pipeline e 14 com pipeline (5 para a primeira, mais 9 de uma em uma). A aceleração é 50 / 14 ≈ 3,6. Não chega a 5 porque o pipeline enche e esvazia, e esses ciclos de preenchimento e esvaziamento não são aproveitados.
  2. Um programa em que o desvio é sempre tomado, como um laço longo, é bem previsto. Um programa em que o desvio depende de um dado aleatório, como um teste sobre números sorteados, erra cerca de metade das vezes. Cada erro descarta as instruções buscadas depois do desvio, e o tempo perdido é a penalidade do erro multiplicada pela frequência de erros.
  3. A figura da hierarquia não tem números. Com os valores medidos nesta nota, a RAM custa 137 a 146 ns e a L1 custa cerca de 2 ns no padrão espalhado, o que dá uma razão de 60 a 70 vezes. Essa estimativa é aproximada porque o padrão espalhado inclui o custo da conta de endereço, e a carga de fundo da máquina distorce a medição. Para confirmar, repetir a medição em máquina sem carga e medir a latência de L1 com um padrão que caiba só nela.

Exercícios novos com resposta:

  1. Um programa executa 3 bilhões de instruções com CPI de 1,0 num núcleo de 3 GHz. Quanto tempo leva? Resposta: 3 × 10^9 × 1,0 × (1 / 3 × 10^9) = 1 s.
  2. O mesmo programa tem 2% de instruções que erram na cache e cada erro custa 150 ciclos. O CPI passa a 1,0 + 0,02 × 150 = 4,0. Quanto tempo leva agora, no mesmo núcleo? Resposta: 3 × 10^9 × 4,0 × (1 / 3 × 10^9) = 4 s. A mesma máquina fica quatro vezes mais lenta por causa da memória.
  3. Um laço de 100 voltas com um contador de dois bits erra quantas vezes por execução completa? Resposta: uma vez, na saída, desde que o contador já esteja em 11 ou 10 na entrada.

Fontes

Consultadas em 1 de novembro de 2026. As informações sobre pipeline, perigos, previsão, execução fora de ordem, MMU e interrupções são conceitos de arquitetura de computadores consolidados. Os dados de produtos citados, como o Pentium e a contagem de duas instruções por ciclo, vêm da página da Wikipedia sobre o Pentium original, listada na nota de história.

  • Wikipedia: Pentium (original): primeiro x86 superescalar e duas instruções por ciclo em muitos casos.
  • Wikipedia: P6 (microarchitecture): execução especulativa e fora de ordem, chamada de execução dinâmica pela Intel.
  • Wikipedia: Pentium Pro: tabela de pipelines, com 12 estágios para a família P6, e processo de 0,50 µm no modelo de 150 MHz.
  • Chips and Cheese: Lion Cove, Intel’s P-core roars: latências de L1 de 4 ciclos (48 KB), L1 intermediária de 192 KB com 9 ciclos, L2 de 17 ciclos e L3 de 51 ciclos. A L2 e a L3 são citadas por tabela da publicação, em configuração específica.
  • Chips and Cheese: SiFive P550: núcleo de três vias fora de ordem, com 13 estágios.
  • AMD Hot Chips 2024: Zen 5: buffer de reordenação de 448 entradas, cache de micro-operações de 6K e decodificação em duas vias de 4.
  • Números de estágio do Core 2 (14) e do Pentium 4 Prescott (31) vêm de publicações de época e de tabelas de pipeline encontradas em busca, sem a página do fabricante aberta nesta revisão. Contagens de estágio não são comparáveis entre fabricantes.
  • Medição de cache: programa desta nota, compilado com o Apple clang 21 e rodado em 1 de novembro de 2026 num Apple M1 Max (macOS 27.0.1). Os dados de tamanho de cache vêm do sysctl da mesma máquina.
  • Os valores de capacidade de cache de modelos específicos estão nas figuras da nota de gerações, com as fontes de cada um.

Relacionados

  • CPU
  • Como a CPU executa instruções
  • Gerações e arquiteturas
  • Limites e segurança
  • Carga da CPU no Mac
  • Profiling

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?