O que todo programador deve saber sobre memory?

Eu estou querendo saber quanto do que cada programador deve saber sobre memory de Ulrich Drepper de 2007 ainda é válido. Também não consegui encontrar uma versão mais recente que 1.0 ou uma errata.

Tanto quanto me lembro, o conteúdo do Drepper descreve conceitos fundamentais sobre a memory: como funciona o cache da CPU, o que são memory física e virtual e como o kernel do Linux lida com esse zoológico. Provavelmente existem referências de API desatualizadas em alguns exemplos, mas isso não importa; isso não afetará a relevância dos conceitos fundamentais.

Assim, qualquer livro ou artigo que descreva algo fundamental não pode ser chamado de desatualizado. “O que todo programador deve saber sobre memory” definitivamente vale a pena ler, mas, bem, não acho que seja para “todo programador”. É mais adequado para pessoas com sistema / embedded / kernel.

Do meu olhar rápido, parece bastante preciso. A única coisa a notar é a parte da diferença entre controladores de memory “integrados” e “externos”. Desde o lançamento dos processadores Intel da linha i7, todos estão integrados, e a AMD usa controladores de memory integrados desde o lançamento dos chips AMD64.

Desde que este artigo foi escrito, não mudou muita coisa, as velocidades ficaram mais altas, os controladores de memory se tornaram muito mais inteligentes (o i7 atrasará as gravações na RAM até parecer que está comprometendo as alterações), mas não mudou muito . Pelo menos não de qualquer maneira que um desenvolvedor de software se importasse.

O guia em formato PDF está em https://www.akkadia.org/drepper/cpumemory.pdf .

Ainda é geralmente excelente e altamente recomendado (por mim, e acho que por outros especialistas em ajuste de desempenho). Seria legal se Ulrich (ou qualquer outra pessoa) escrevesse uma atualização de 2017, mas isso seria muito trabalhoso (por exemplo, re-executar os benchmarks). Veja também outros links de otimização do desempenho x86 e SSE / asm (e C / C ++) no wiki da marca x86 . (O artigo de Ulrich não é específico para x86, mas a maioria dos seus benchmarks estão em hardware x86.)

Os detalhes de hardware de baixo nível sobre como a DRAM e os caches funcionam ainda se aplicam . A DDR4 usa os mesmos comandos descritos para DDR1 / DDR2 (burst de leitura / gravação). As melhorias do DDR3 / 4 não são mudanças fundamentais. AFAIK, todo o material independente de arco ainda se aplica geralmente, por exemplo, para AArch64 / ARM32.

Veja também a seção Latency Bound Platforms desta resposta para detalhes importantes sobre o efeito de latência de memory / L3 na largura de bandwidth < = max_concurrency / latency single-threaded: bandwidth < = max_concurrency / latency , e este é realmente o principal gargalo para single-threaded bandwidth em um moderno muitos CPU de alto desempenho como um Xeon. (Mas um desktop Skylake de quatro núcleos pode chegar perto de maximizar a largura de banda de DRAM com um único thread). Esse link tem algumas informações muito boas sobre as lojas do NT em comparação às lojas normais no x86.

Assim, a sugestão de Ulrich em 6.5.8 Utilizando Toda a Largura de Banda (usando memory remota em outros nós NUMA, assim como a sua própria) é contra-produtiva em hardware moderno onde controladores de memory têm mais largura de banda do que um único núcleo pode usar. Bem, possivelmente você pode imaginar uma situação em que há algum benefício em executar vários encadeamentos com memory insuficiente no mesmo nó NUMA para comunicação intercalar de baixa latência, mas fazer com que eles usem memory remota para material não sensível à latência de alta largura de banda. Mas isso é bem obscuro; Normalmente, em vez de usar intencionalmente memory remota quando você poderia ter usado local, basta dividir os threads entre os nós NUMA e fazer com que usem a memory local.


(normalmente) Não use pré-busca de software

Uma coisa importante que mudou é que a pré-busca de hardware é muito melhor do que em P4 e pode reconhecer padrões de access restritos até strides razoavelmente grandes e múltiplos streams de uma só vez (por exemplo, frente / trás por página 4k). O manual de otimização da Intel descreve alguns detalhes dos pré-buscadores HW em vários níveis de cache para sua microarquitetura da família Sandybridge. A Ivybridge e, mais tarde, a pré-busca de hardware da próxima página, em vez de esperar por um erro de cache na nova página para acionar um início rápido. (Eu suponho que a AMD tenha algo semelhante em seu manual de otimização.) Cuidado com o fato de que o manual da Intel também está cheio de conselhos antigos, alguns dos quais são bons apenas para o P4. As seções específicas do Sandybridge são naturalmente precisas para o SnB, mas por exemplo, a não laminação de uops micro fundidos é alterada no HSW e o manual não o menciona .

O conselho usual hoje em dia é remover toda a pré-busca SW do código antigo , e considerar apenas colocá-la de volta se a criação de perfil mostrar falhas de cache (e você não estiver saturando a largura de banda da memory). A pré-busca dos dois lados da próxima etapa de uma pesquisa binária ainda pode ajudar. por exemplo, uma vez que você decida qual elemento olhar em seguida, pré-busque os elementos de 1/4 e 3/4 para que eles possam carregar em paralelo com o carregamento / verificação do meio.

A sugestão de usar um thread prefetch separado (6.3.4) é totalmente obsoleta , eu acho, e só foi boa no Pentium 4. O P4 tinha hyperthreading (2 núcleos lógicos compartilhando um núcleo físico), mas não o suficiente fora de ordem resources de execução ou cache de rastreamento para obter taxa de transferência executando dois encadeamentos de computação completos no mesmo núcleo. Mas as CPUs modernas (Sandybridge-family e Ryzen) são muito mais robustas e devem executar um thread real ou não usar hyperthreading (deixe o outro núcleo lógico ocioso para que o segmento solo tenha os resources completos).

A pré-busca de software sempre foi "frágil" : os números de sintonização mágica corretos para obter uma aceleração dependem dos detalhes do hardware e talvez da carga do sistema. Muito cedo e é despejado antes da carga de demanda. Tarde demais e isso não ajuda. Este artigo do blog mostra códigos + charts para uma experiência interessante no uso da pré-busca de SW no Haswell para pré-buscar a parte não sequencial de um problema. Veja também Como usar corretamente as instruções de pré-busca? . A pré-busca do NT é interessante, mas ainda mais frágil (porque um despejo antecipado de L1 significa que você precisa percorrer todo o caminho até L3 ou DRAM, não apenas L2). Se você precisa da última gota de desempenho, e você pode sintonizar uma máquina específica, vale a pena olhar para o access seqüencial, mas ainda pode ser uma lentidão se você tiver bastante trabalho da ALU para fazer enquanto estiver perto de gargalos na memory .


O tamanho da linha de cache ainda é de 64 bytes. (A largura de banda de leitura / gravação de L1D é muito alta e os processadores modernos podem fazer 2 carregamentos de vetor por clock + 1 loja de vetores se tudo ocorrer em L1D. Veja Como o cache pode ser tão rápido? ) Com AVX512, tamanho de linha = largura de vetor, então você pode carregar / armazenar uma linha de cache inteira em uma instrução. (E, portanto, cada carga / loja desalinhada cruza um limite de linha de cache, em vez de todos os outros para 256b AVX1 / AVX2, o que geralmente não desacelera o loop em uma matriz que não estava em L1D.)

As instruções de carregamento não alinhadas têm zero de penalidade se o endereço estiver alinhado no tempo de execução, mas os compiladores (especialmente o gcc) criam um código melhor ao fazer a autovectorização, se souberem sobre quaisquer garantias de alinhamento. Operações atualmente desalinhadas são geralmente rápidas, mas as divisões de página ainda doem (muito menos no Skylake, porém; apenas ~ 11 ciclos extras de latência vs. 100, mas ainda assim uma penalidade de throughput).


Como Ulrich previu, todo sistema multi-socket é NUMA atualmente: controladores de memory integrados são padrão, ou seja, não há Northbridge externo. Mas o SMP já não significa multi-socket, porque as CPUs multi-core são difundidas. (Os processadores da Intel da Nehalem para a Skylake usaram um grande cache L3 inclusivo como backstop para a coerência entre os núcleos.) Os processadores da AMD são diferentes, mas eu não sou tão claro nos detalhes.

O Skylake-X (AVX512) não tem mais um L3, mas acho que ainda há um diretório de tags que permite verificar o que está em cache em qualquer lugar no chip (e, se sim, onde) sem realmente transmitir snoops para todos os núcleos. O SKX usa uma malha em vez de um barramento de anel , com latência geralmente pior do que os Xeons de muitos núcleos anteriores, infelizmente.

Basicamente, todos os conselhos sobre como otimizar o posicionamento da memory ainda se aplicam, apenas os detalhes de exatamente o que acontece quando você não pode evitar falhas de cache ou contenção variam.


6.4.2 Ops atômicas : o benchmark mostrando um loop de repetição de CAS como 4x pior do que o lock add arbitragem de hardware provavelmente ainda reflete um caso de contenção máxima . Mas em programas multi-threaded reais, a synchronization é mantida a um mínimo (porque é cara), então a contenção é baixa e um loop de repetição de CAS normalmente é bem-sucedido sem ter que tentar novamente.

C ++ 11 std::atomic fetch_add irá compilar para um lock add (ou lock xadd se o valor de retorno for usado), mas um algoritmo usando CAS para fazer algo que não pode ser feito com uma instrução locked normalmente não é um desastre. Use C ++ 11 std::atomic ou C11 stdatomic vez de built-ins __sync herdados do gcc ou os novos __atomic built-ins, a menos que você queira misturar o access atômico e não-atômico ao mesmo local ...

8.1 DCAS ( cmpxchg16b ) : Você pode convencer o gcc a emiti-lo, mas se você quer cargas eficientes de apenas metade do object, você precisa de hacks feios: Como posso implementar o contador ABA com o c ++ 11 CAS?

8.2.4 memory transacional : Depois de algumas falsas partidas (liberadas e desativadas por uma atualização de microcódigo por causa de um bug raramente acionado), a Intel possui memory transacional em funcionamento no modelo final Broadwell e em todas as CPUs Skylake. O design ainda é o que David Kanter descreveu para Haswell . Há uma maneira de usá-lo para acelerar o código que usa (e pode voltar a) um bloqueio regular (especialmente com um único bloqueio para todos os elementos de um contêiner, portanto, vários encadeamentos na mesma seção crítica geralmente não colidem ), ou para escrever código que sabe sobre transactions diretamente.


7.5 Hugepages : as abreviaturas anónimas transparentes funcionam bem no Linux sem ter que usar manualmente o hugetlbfs. Faça alocações> = 2MiB com alinhamento de 2MiB (por exemplo , posix_memalign ou um aligned_alloc que não imponha o requisito estúpido do ISO C ++ 17 de falhar quando o size % alignment != 0 for igual a size % alignment != 0 ).

Uma alocação anônima alinhada com 2MiB usará as hugepages por padrão. Algumas cargas de trabalho (por exemplo, que continuam usando grandes alocações por um tempo depois de produzi-las) podem se beneficiar de
echo always >/sys/kernel/mm/transparent_hugepage/defrag para fazer o kernel desfragmentar a memory física sempre que necessário, em vez de voltar para 4k páginas. (Veja os documentos do kernel ). Como alternativa, use madvise(MADV_HUGEPAGE) depois de fazer grandes alocações (preferencialmente ainda com alinhamento de 2MiB).


Apêndice B: Oprofile : O Linux perf substituiu principalmente o oprofile . Para events detalhados específicos de certas microarquiteturas, use o wrapper ocperf.py . por exemplo

 ocperf.py stat -etask-clock,context-switches,cpu-migrations,page-faults,cycles,\ branches,branch-misses,instructions,uops_issued.any,\ uops_executed.thread,idq_uops_not_delivered.core -r2 ./a.out 

Para alguns exemplos de uso, veja o MOV do Can x86 realmente "livre"? Por que não consigo reproduzir isso? .