Resumo
Com base nos fundamentos estabelecidos em XBOX One vs XBOX Series X|S, este artigo aborda os problemas de desempenho e os custos associados a diferentes operações de leitura/gravação na memória compartilhada entre núcleos do processador. Para relembrar a arquitetura, os recursos e o desempenho das CPUs da família XBOX One e dos consoles XBOX Series, sugerimos que você consulte o artigo mencionado. Introdução Dados compartilhados Os custos do compartilhamento de dados Recomendações Apêndice: códigoIntrodução
XBOX One vs XBOX Series X|S descreve o custo associado a uma falha de cache L1. Um dos padrões mais comuns de falha de cache L1 é a gravação em um endereço compartilhado entre núcleos da CPU — por exemplo, dados compartilhados entre vários threads. O objetivo deste artigo é fornecer números relativos para a penalidade associada ao compartilhamento de dados graváveis entre núcleos. A principal conclusão dos nossos testes é que é preferível confinar os dados em operações de leitura/gravação a cada núcleo individual, em vez de compartilhar dados entre núcleos e threads. Compartilhar dados entre núcleos tornará o processamento do seu código mais lento, em vez de acelerá-lo. A família XBOX One usa um processador Jaguar e os consoles XBOX Series usam um processador Hercules. Ambos os processadores usam o protocolo MOESI - Wikipédia para manter um modelo de memória forte e a coerência de cache. Cada linha de cache pode estar em um de cinco estados.
- Modified (Modificado)
- Este processador tem a única cópia válida da linha de cache e fez alterações.
- A linha de cache não corresponde à memória.
- Owned (Proprietário)
- Este processador tem a única cópia válida e fez alterações.
- Outros processadores podem ter uma cópia somente leitura.
- Exclusive (Exclusivo)
- O processador tem a única cópia da linha de cache.
- O conteúdo da linha de cache corresponde à memória.
- Shared (Compartilhado)
- Este processador tem uma de várias cópias da linha de cache.
- Outro processador pode tê-la no estado Owned se alterações tiverem sido feitas
- Invalid (Inválido)
- Esta linha de cache não é válida e precisa ser buscada antes do acesso.
Dados compartilhados
O compartilhamento de dados pode acontecer de várias maneiras. Uma das mais óbvias é o compartilhamento direto de variáveis, como uma contagem de referências em uma estrutura. Isso pode facilmente resultar na modificação dos dados à medida que cada thread faz cópias por meio de um shared_ptr, por exemplo, como parâmetro em uma chamada de função. Essa operação por si só resultará em contenção de recursos. Outro tipo de compartilhamento de dados, conhecido como compartilhamento falso, é mais difícil de detectar. O compartilhamento falso ocorre quando dois endereços de memória são resolvidos para a mesma linha de cache. A linha de cache L1 dos processadores Jaguar e Hercules tem 64 bytes de tamanho e é alinhada a 64 bytes, de modo que dois inteiros no mesmo bloco de 64 bytes são considerados compartilhados. Considere, por exemplo, as duas estruturas de dados a seguir:
Os custos do compartilhamento de dados
Perfil de teste
O perfil de teste seguiu estes critérios:- As instruções testadas foram uma leitura bruta, uma gravação bruta, um carregamento atômico, um armazenamento atômico e uma operação atômica de comparação e armazenamento (CAS). O código usado para cada conjunto de instruções é fornecido no Apêndice: código.
- Os testes foram realizados com um local de memória de 8 bytes compartilhado por todos os threads (linha de cache compartilhada) ou com cada thread tendo um local de memória exclusivo de 8 bytes (linha de cache exclusiva).
- Quarenta mil operações foram executadas em um loop compacto.
- O otimizador foi configurado para minimizar o tamanho, a fim de desabilitar o desenrolamento de loop.
- Foi realizado um total de 100 execuções; a mediana de todas as execuções é mostrada nas tabelas a seguir.
Teste em um único núcleo
Para nossa linha de base, começamos testando cada operação em um único núcleo. | Operação | XBOX One | XBOX One X | XBOX Series S - 3.4 GHz | XBOX Series X|S - 3.6 GHz | XBOX Series X - 3.8 GHz | |--------------|----------:|----------:|----------:|----------:|----------:| | Leitura bruta | 68.78 us | 52.34 us | 11.81 us | 11.16 us | 10.56 us | | Carregamento atômico | 68.78 us | 52.34 us | 11.81 us | 11.16 us | 10.56 us | | Gravação bruta | 114.61 us | 104.64 us | 11.81 us | 11.16 us | 10.56 us | | Armazenamento atômico | 369.55 us | 278.99 us | 207.11 us | 195.61 us | 185.31 us | | CAS atômico | 668.79 us | 509.02 us | 203.43 us | 192.13 us | 182.03 us | O primeiro ponto é a diferença de tempos entre o processador Jaguar e o processador Hercules. Há um aumento de desempenho de até quatro vezes no Hercules. Isso se deve a uma combinação de maior velocidade de clock, maior largura de banda e suporte a mais operações de memória simultâneas em andamento Comparando 40.000 leituras diretas com 40.000 carregamentos atômicos, não há nenhuma diferença nos tempos. Isso se deve ao modelo de memória forte da CPU e ao protocolo MOESI. O compilador consegue gerar as mesmas instruções para ambas as operações. As operações de gravação bruta e armazenamento atômico neste teste tentam incrementar um valor na memória. Neste teste, há um aumento de duas vezes no custo entre uma gravação bruta e um armazenamento atômico. Isso ocorre porque o armazenamento atômico usa a instrução xchg, que inclui implicitamente o prefixo lock. Isso pode paralisar o processador até que os resultados tenham sido liberados para o cache, porque ele não pode reordenar operações entre instruções que usam o prefixo lock. A gravação bruta usa a instrução mov. Ela não paralisa o processador, que tem liberdade para reordenar operações em torno da instrução mov. Isso nos leva à operação atômica de comparação e armazenamento (CAS). A operação deste teste é semelhante aos testes de gravação bruta e armazenamento atômico: tentar incrementar um valor na memória. O prefixo lock é usado com a instrução cmpxchg neste teste para torná-lo comparável ao teste de armazenamento atômico. No processador Jaguar, a operação CAS é mais cara que a instrução xchg mencionada anteriormente, devido à comparação. No entanto, o processador Hercules tem uma implementação aprimorada que mantém o custo semelhante ao de um armazenamento atômico.Teste em vários núcleos
Todos os testes de vários núcleos usaram dois threads em diversas configurações de núcleos.- Dois threads em núcleos físicos separados, mas no mesmo cluster.
- Dois threads em núcleos físicos separados e em clusters separados.
- Onde o SMT estava habilitado, dois threads em execução nos dois núcleos lógicos de um único processador físico.
Leitura bruta/Carregamento atômico
Esta é uma leitura direta de um local de memória. Não há contenção entre os threads, porque nada está alterando os dados: cada thread tem sua própria cópia dos dados em seu cache L1. Como resultado, os tempos de leitura de dados compartilhados e de leitura de dados exclusivos são os mesmos. Devido ao modelo de memória forte, o compilador pode gerar o mesmo código para um carregamento atômico e para uma leitura normal. Isso significa que os tempos são os mesmos para cada operação. | Teste | XBOX One | XBOX One X | XBOX Series X|S - SMT | XBOX Series X|S - sem SMT | |--------------|----------:|----------:|----------:|----------:|----------:| | Núcleo único | 1.00 | 1.00 | 1.00 | 1.00 | | Mesmo físico, compartilhado | N/A | N/A | 1.97 | N/A | | Mesmo cluster, compartilhado | 1.00 | 1.00 | 1.00 | 1.00 | | Entre clusters, compartilhado | 1.00 | 1.00 | 1.00 | 1.00 | | Mesmo físico, exclusivo | N/A | N/A | 1.97 | N/A | | Mesmo cluster, exclusivo | 1.00 | 1.00 | 1.00 | 1.00 | | Entre clusters, exclusivo | 1.00 | 1.00 | 1.00 | 1.00 | Há duas conclusões principais desta tabela. A primeira é que apenas ler da memória não tem efeito sobre o desempenho, mesmo quando vários núcleos estão lendo do mesmo local. Cada processador tem uma cópia válida em seu cache, e sua linha de cache está no estado Shared, já que mais de um processador tem uma cópia. A segunda é a duplicação do tempo na configuração em que ambos os threads estão sendo executados no mesmo núcleo físico. Quando o SMT está habilitado, os recursos do núcleo são compartilhados entre dois threads. Isso pode resultar em ganho de desempenho, porque frequentemente um thread não consegue utilizar todos os recursos disponíveis. No entanto, este teste é um loop muito compacto que domina a unidade de carregamento/armazenamento (Load/Store) e o cache L1. Um único thread consegue usar todos os slots da unidade Load/Store; portanto, os dois threads precisam compartilhá-los em formato round-robin. O efeito líquido é que cada thread leva o dobro do tempo, mas o núcleo realiza o dobro do trabalho. No geral, o núcleo realiza a mesma quantidade de trabalho no mesmo período.Gravação bruta
Uma gravação simples na memória compartilhada entre núcleos será significativamente mais lenta que uma gravação por um único núcleo em um local de memória não compartilhado. A operação de gravação faz com que o núcleo atualize a linha de cache e invalide quaisquer cópias em outros núcleos. Este teste específico é um incremento de um valor na memória compartilhada. Isso significa que, se o núcleo não tiver uma cópia válida em seu cache, ele precisará solicitar os dados da memória ou do último gravador do endereço. Isso pode criar um efeito pingue-pongue entre os dois núcleos, em que um núcleo atualiza os dados, o segundo núcleo lê os dados do primeiro e os atualiza e, em seguida, o primeiro núcleo precisa ler os dados do outro núcleo antes de poder atualizá-los. Isso continua de um lado para outro a cada iteração do loop de teste. A sobrecarga do efeito pingue-pongue pode ser vista nesta tabela. | Teste | XBOX One | XBOX One X | XBOX Series X|S - SMT | XBOX Series X|S - sem SMT | |--------------|----------:|----------:|----------:|----------:|----------:| | Núcleo único | 1.00 | 1.00 | 1.00 | 1.00 | | Mesmo físico, compartilhado | N/A | N/A | 10.69 | N/A | | Mesmo cluster, compartilhado | 10.54 | 8.73 | 24.36 | 23.59 | | Entre clusters, compartilhado | 13.61 | 12.78 | 18.89 | 23.57 | | Mesmo físico, exclusivo | N/A | N/A | 1.98 | N/A | | Mesmo cluster, exclusivo | 1.05 | 0.93 | 1.03 | 1.03 | | Entre clusters, exclusivo | 1.01 | 0.84 | 1.04 | 1.03 | Há várias conclusões desta tabela. A primeira é a diferença no impacto relativo sobre o desempenho entre os processadores Jaguar e Hercules. No Hercules, o custo de compartilhar uma linha de cache é maior que no Jaguar. Nesse caso, o Jaguar tem um custo de 17 ciclos para solicitar os dados do outro núcleo; a mesma operação no Hercules custa 90 ciclos. Isso se deve ao cache mais profundo do Hercules, com a existência de um cache L3 ausente no Jaguar. A segunda é a diferença entre usar um local de memória compartilhado e um local exclusivo. Nesse caso, a operação é um incremento do valor atual, ou seja, uma operação de leitura/modificação/gravação. A operação de leitura precisa ser atendida pelo núcleo que gravou mais recentemente naquele local de memória. Se não for o mesmo núcleo, o custo da leitura é drasticamente maior. Como se pode ver na tabela, isso pode ser até vinte e cinco vezes mais caro. A terceira é a diferença relativa menor quando os dois threads compartilham um único núcleo físico com o SMT habilitado. A linha de cache atualizada não precisa ser buscada de outro núcleo, pois já é local. No entanto, o encaminhamento de armazenamento para carregamento (store-to-load forwarding) não pode ser aplicado. Quando o thread A atualiza o valor, ele precisa ser liberado para o cache antes que o thread B possa usá-lo. A quarta é a duplicação do tempo na configuração em que ambos os threads estão sendo executados no mesmo núcleo físico, mas operando em memória exclusiva. Quando o SMT está habilitado, os recursos do núcleo são compartilhados entre dois threads. Isso pode resultar em ganho de desempenho, porque frequentemente um thread não consegue utilizar todos os recursos disponíveis. No entanto, este teste é um loop muito compacto que domina a unidade Load/Store e o cache L1. Um único thread consegue usar todos os slots da unidade Load/Store; portanto, os dois threads precisam compartilhá-los em formato round-robin. O efeito líquido é que cada thread leva o dobro do tempo, mas o núcleo realiza o dobro do trabalho. No geral, o núcleo realiza a mesma quantidade de trabalho no mesmo período.Armazenamento atômico
O armazenamento atômico usa a instrução xchg, que sinaliza implicitamente o sinalizador lock. A instrução xchg precisa ler e depois gravar no endereço de memória em questão. Durante esse tempo, a linha de cache fica bloqueada, impedindo que os outros núcleos acessem essa linha de cache. Isso pode paralisar uma operação pendente até que a instrução xchg tenha gravado os dados no cache. Outro custo é que o núcleo não pode reordenar operações em torno das instruções xchg. O custo final ocorre se outro núcleo tiver modificado os dados. Nesse caso, os dados precisarão ser buscados desse núcleo. Quaisquer outros núcleos precisarão aguardar durante toda essa sequência ao operar na linha de cache compartilhada. Assim como no teste de gravação bruta, o teste de armazenamento atômico pode ter um efeito pingue-pongue semelhante; porém, neste caso, ele é mais acentuado e afeta até o teste de núcleo único. O processador não consegue executar especulativamente além de uma iteração do loop. O thread A executa a instrução xchg enquanto o thread B aguarda acesso. Assim que o thread A termina, o thread B pode continuar e imediatamente solicita uma cópia antes que o thread A possa solicitar acesso na próxima iteração do loop. Esta tabela mostra o custo relativo da instrução xchg em comparação com um único núcleo executando a operação. | Teste | XBOX One | XBOX One X | XBOX Series X|S - SMT | XBOX Series X|S - sem SMT | |--------------|----------:|----------:|----------:|----------:|----------:| | Núcleo único | 1.00 | 1.00 | 1.00 | 1.00 | | Mesmo físico, compartilhado | N/A | N/A | 2.38 | N/A | | Mesmo cluster, compartilhado | 6.84 | 6.85 | 4.17 | 4.04 | | Entre clusters, compartilhado | 10.64 | 13.40 | 3.72 | 3.56 | | Mesmo físico, exclusivo | N/A | N/A | 0.98 | N/A | | Mesmo cluster, exclusivo | 1.00 | 1.00 | 1.00 | 1.00 | | Entre clusters, exclusivo | 1.01 | 1.00 | 1.00 | 1.00 | Há duas conclusões a partir dos dados desta tabela. Os tempos relativos ao usar um endereço compartilhado não são tão extremos para um armazenamento atômico quanto para uma gravação bruta. O motivo é que uma grande parte do custo deste teste já foi paga no teste de núcleo único. No entanto, o custo geral do armazenamento atômico ainda é de duas a quatro vezes maior que o de uma gravação bruta no Hercules e até treze vezes maior no Jaguar. A segunda é que o Hercules tem uma implementação aprimorada para operações lock, o que, neste caso, faz com que os testes no mesmo cluster e entre clusters levem aproximadamente o mesmo tempo. No processador Jaguar, o custo pode ser até duas vezes maior quando os dados são compartilhados entre clusters e há operações lock.Comparação e armazenamento atômicos (CAS)
A operação atômica de comparação e armazenamento (CAS) neste teste usa a instrução cmpxchg com o prefixo lock, o que significa que, enquanto a operação está sendo executada, a linha de cache fica bloqueada e não pode ser acessada por outros processadores. É da mesma forma que a instrução xchg funciona no teste de armazenamento atômico. Neste teste, a operação CAS realiza o mesmo cálculo que a operação de armazenamento atômico. Ela tenta incrementar o valor na memória, seja compartilhada ou exclusiva. A única diferença é que a operação CAS só gravará no local de memória se ele for igual a um terceiro valor especificado. | Teste | XBOX One | XBOX One X | XBOX Series X|S - SMT | XBOX Series X|S - sem SMT | |--------------|----------:|----------:|----------:|----------:|----------:| | Núcleo único | 1.00 | 1.00 | 1.00 | 1.00 | | Mesmo físico, compartilhado | N/A | N/A | 2.91 | N/A | | Mesmo cluster, compartilhado | 4.32 | 4.32 | 4.01 | 4.24 | | Entre clusters, compartilhado | 10.12 | 14.80 | 3.52 | 3.45 | | Mesmo físico, exclusivo | N/A | N/A | 0.91 | N/A | | Mesmo cluster, exclusivo | 1.00 | 1.00 | 1.00 | 1.00 | | Entre clusters, exclusivo | 1.00 | 1.00 | 1.00 | 1.00 | Como os testes de armazenamento atômico e de comparação e armazenamento atômicos usam o prefixo lock, seus custos relativos são os mesmos e pelos mesmos motivos. Os tempos relativos entre operações de thread único e multithread usando um endereço compartilhado não são tão extremos porque uma grande parte do custo deste teste já foi paga no teste de núcleo único. No entanto, o custo geral do CAS ainda é de duas a quatro vezes maior quando os dados são compartilhados no Hercules e até quatorze vezes maior no Jaguar. A segunda é que o Hercules tem uma implementação aprimorada para operações lock, o que, neste caso, faz com que os testes no mesmo cluster e entre clusters levem aproximadamente o mesmo tempo. No processador Jaguar, o custo pode ser até duas vezes maior quando os dados são compartilhados entre clusters e operações lock são usadas.Recomendações
Há vários padrões importantes que você pode seguir para evitar o compartilhamento de dados graváveis entre núcleos:
- Estrutura de dados: mude de uma matriz de estruturas (AoS) para uma estrutura de matrizes (SoA), em que cada thread opera em uma estrutura exclusiva.
- Separe os dados somente leitura dos dados de leitura/gravação.
- Preenchimento: você pode preencher uma estrutura com um múltiplo do tamanho de uma linha de cache (64 bytes). Isso eliminará o compartilhamento falso se cada thread operar em uma estrutura.
- Contagem de referências (shared_ptr, weak_ptr e assim por diante): ambos têm uma única variável de contagem de referências que é compartilhada entre todas as instâncias do objeto.
- Seja muito claro quanto à propriedade dos dados. Quando os dados são passados para outro thread para processamento, esse thread passa a ser o proprietário dos dados. Evite fazer qualquer coisa com os dados até que esse thread termine de usá-los.
- Fila de trabalhos compartilhada: tente evitar o uso de uma única fila de trabalhos compartilhada por vários threads, especialmente se os trabalhos forem pequenos. Em vez disso, considere ter uma fila de trabalhos exclusiva para cada thread. Você pode usar o algoritmo de roubo de trabalho (work stealing) para ajudar a equilibrar a carga entre os threads.
