O que é um código "compatível com cache"?

739

Qual é a diferença entre o " código hostil do cache " e o código " amigável ao cache "?

Como posso garantir que escrevo código com cache eficiente?

Noah Roth
fonte
28
Isso pode lhe dar uma dica: stackoverflow.com/questions/9936132/…
Robert Martin #
4
Também esteja ciente do tamanho de uma linha de cache. Nos processadores modernos, geralmente são 64 bytes.
John Dibling
3
Aqui está outro artigo muito bom. Os princípios aplicam-se a C / C ++ programas em qualquer sistema operacional (Linux, Maxos ou Windows): lwn.net/Articles/255364
paulsm4
4
Pergunta relacionada: stackoverflow.com/questions/8469427/…
Matt
stackoverflow.com/questions/763262/…
Ciro Santilli escreveu:

Respostas:

966

Preliminares

Nos computadores modernos, apenas as estruturas de memória de nível mais baixo (os registros ) podem mover dados em ciclos de relógio único. No entanto, os registros são muito caros e a maioria dos núcleos de computadores possui menos de algumas dezenas de registros (algumas centenas a talvez mil bytes no total). No outro extremo do espectro da memória ( DRAM ), a memória é muito barata (isto é, literalmente milhões de vezes mais barata ), mas leva centenas de ciclos após uma solicitação para receber os dados. Para preencher essa lacuna entre super rápido e caro e super lento e barato, estão as memórias de cache, chamado L1, L2, L3 em velocidade e custo decrescentes. A ideia é que a maior parte do código em execução atinja um pequeno conjunto de variáveis ​​com frequência, e o restante (um conjunto muito maior de variáveis) com pouca frequência. Se o processador não conseguir encontrar os dados no cache L1, ele procurará no cache L2. Se não houver, o cache L3 e, se não houver, a memória principal. Cada uma dessas "falhas" é cara no tempo.

(A analogia é que a memória cache é a memória do sistema, pois a memória do sistema é muito armazenamento em disco rígido. O armazenamento em disco rígido é super barato, mas muito lento).

O armazenamento em cache é um dos principais métodos para reduzir o impacto da latência . Parafraseando Herb Sutter (cfr. Links abaixo): aumentar a largura de banda é fácil, mas não podemos comprar a latência .

Os dados são sempre recuperados pela hierarquia de memória (menor == do mais rápido para o mais lento). Um hit / miss do cache geralmente se refere a um hit / miss no nível mais alto de cache da CPU - por nível mais alto, quero dizer o maior == mais lento. A taxa de acertos do cache é crucial para o desempenho, pois cada falta de cache resulta na busca de dados da RAM (ou pior ...) que leva muito tempo (centenas de ciclos para RAM, dezenas de milhões de ciclos para HDD). Em comparação, a leitura de dados do cache (nível mais alto) normalmente leva apenas alguns ciclos.

Nas arquiteturas modernas de computadores, o gargalo de desempenho está deixando a CPU morrer (por exemplo, acessando a RAM ou superior). Isso só vai piorar com o tempo. Atualmente, o aumento da frequência do processador não é mais relevante para aumentar o desempenho. O problema é o acesso à memória. Portanto, os esforços de design de hardware nas CPUs concentram-se atualmente na otimização de caches, pré-busca, pipelines e simultaneidade. Por exemplo, as CPUs modernas gastam cerca de 85% dos dados em caches e até 99% para armazenar / mover dados!

Há muito a ser dito sobre o assunto. Aqui estão algumas ótimas referências sobre caches, hierarquias de memória e programação adequada:

Principais conceitos para código compatível com cache

Um aspecto muito importante do código compatível com o cache é sobre o princípio da localidade , cujo objetivo é colocar dados relacionados próximos na memória para permitir o armazenamento em cache eficiente. Em termos de cache da CPU, é importante estar ciente das linhas de cache para entender como isso funciona: Como as linhas de cache funcionam?

Os seguintes aspectos particulares são de grande importância para otimizar o armazenamento em cache:

  1. Localidade temporal : quando um determinado local da memória foi acessado, é provável que o mesmo local seja acessado novamente em um futuro próximo. Idealmente, essas informações ainda serão armazenadas em cache nesse ponto.
  2. Localidade espacial : refere-se à colocação de dados relacionados próximos um do outro. O armazenamento em cache ocorre em vários níveis, não apenas na CPU. Por exemplo, quando você lê da RAM, normalmente é buscada uma parte maior da memória do que a solicitada especificamente, porque muitas vezes o programa exige esses dados em breve. Os caches de HDD seguem a mesma linha de pensamento. Especificamente para caches de CPU, a noção de linhas de cache é importante.

Use apropriado recipientes

Um exemplo simples de amigável ao cache versus hostil ao cache é é std::vectorcontra std::list. Os elementos de a std::vectorsão armazenados na memória contígua e, como tal, acessá-los é muito mais compatível com o cache do que acessar elementos em um std::list, que armazena seu conteúdo em todo o lugar. Isto é devido à localidade espacial.

Uma ilustração muito boa disso é dada por Bjarne Stroustrup neste clipe do youtube (obrigado a @Mohammad Ali Baydoun pelo link!).

Não negligencie o cache na estrutura de dados e no design de algoritmos

Sempre que possível, tente adaptar as estruturas de dados e a ordem dos cálculos de uma maneira que permita o máximo uso do cache. Uma técnica comum nesse sentido é o bloqueio de cache (versão Archive.org) , que é de extrema importância na computação de alto desempenho (cfr. Por exemplo, ATLAS ).

Conhecer e explorar a estrutura implícita de dados

Outro exemplo simples, que muitas pessoas no campo às vezes esquecem é a coluna principal (ex. ,) x pedidos principais (ex. ,) para armazenar matrizes bidimensionais. Por exemplo, considere a seguinte matriz:

1 2
3 4

Na ordenação de linhas principais, isso é armazenado na memória como 1 2 3 4; na ordenação principal da coluna, isso seria armazenado como 1 3 2 4. É fácil ver que implementações que não exploram essa ordem rapidamente se deparam com problemas de cache (facilmente evitáveis!). Infelizmente, vejo coisas assim com muita frequência no meu domínio (aprendizado de máquina). O @MatteoItalia mostrou esse exemplo com mais detalhes em sua resposta.

Ao buscar um determinado elemento de uma matriz da memória, os elementos próximos a ela também serão buscados e armazenados em uma linha de cache. Se a ordem for explorada, isso resultará em menos acessos à memória (porque os próximos valores necessários para cálculos subsequentes já estão em uma linha de cache).

Por uma questão de simplicidade, assuma que o cache compreende uma única linha de cache que pode conter 2 elementos da matriz e que, quando um determinado elemento é buscado na memória, o próximo também. Digamos que queremos assumir a soma de todos os elementos no exemplo 2x2 da matriz acima (vamos chamá-lo M):

Explorar a ordem (por exemplo, alterar o índice da coluna primeiro em ):

M[0][0] (memory) + M[0][1] (cached) + M[1][0] (memory) + M[1][1] (cached)
= 1 + 2 + 3 + 4
--> 2 cache hits, 2 memory accesses

Não explorar a ordem (por exemplo, alterar o índice de linhas primeiro em ):

M[0][0] (memory) + M[1][0] (memory) + M[0][1] (memory) + M[1][1] (memory)
= 1 + 3 + 2 + 4
--> 0 cache hits, 4 memory accesses

Neste exemplo simples, explorar a ordem dobra aproximadamente a velocidade de execução (já que o acesso à memória requer muito mais ciclos do que computar as somas). Na prática, a diferença de desempenho pode ser muito maior.

Evite ramificações imprevisíveis

As arquiteturas modernas apresentam pipelines e compiladores estão se tornando muito bons em reordenar o código para minimizar atrasos devido ao acesso à memória. Quando seu código crítico contém ramificações (imprevisíveis), é difícil ou impossível pré-buscar dados. Isso levará indiretamente a mais falhas de cache.

Isso é explicado muito bem aqui (graças a @ 0x90 pelo link): Por que o processamento de uma matriz classificada é mais rápido que o processamento de uma matriz não classificada?

Evite funções virtuais

No contexto de , os virtualmétodos representam um problema polêmico com relação às falhas de cache (existe um consenso geral de que elas devem ser evitadas quando possível em termos de desempenho). As funções virtuais podem induzir falhas de cache durante a pesquisa, mas isso só acontece se a função específica não for chamada com frequência (caso contrário, provavelmente seria armazenada em cache); portanto, isso é considerado um problema não para alguns. Para referência sobre esse problema, confira: Qual é o custo de desempenho de ter um método virtual em uma classe C ++?

Problemas comuns

Um problema comum em arquiteturas modernas com caches de multiprocessadores é chamado compartilhamento falso . Isso ocorre quando cada processador individual está tentando usar dados em outra região de memória e tenta armazená-los na mesma linha de cache . Isso faz com que a linha de cache - que contém dados que outro processador possa usar - seja substituída várias vezes. Efetivamente, diferentes encadeamentos aguardam, induzindo falhas de cache nessa situação. Veja também (obrigado a @Matt pelo link): Como e quando alinhar ao tamanho da linha do cache?

Um sintoma extremo de armazenamento em cache insuficiente na memória RAM (que provavelmente não é o que você quer dizer neste contexto) é o chamado thrashing . Isso ocorre quando o processo gera continuamente falhas na página (por exemplo, acessa a memória que não está na página atual) que requer acesso ao disco.

Marc Claesen
fonte
27
talvez você possa expandir a resposta um pouco, explicando também que, em dados code--multithreaded também pode ser muito local (por exemplo, o falso compartilhamento)
TemplateRex
2
Pode haver tantos níveis de cache quanto os projetistas de chips acharem úteis. Geralmente eles estão equilibrando velocidade versus tamanho. Se você pudesse tornar seu cache L1 tão grande quanto L5 e tão rápido quanto, você precisaria apenas de L1.
Rafael Baptista
24
Percebo que postagens vazias de contrato são reprovadas no StackOverflow, mas essa é honestamente a melhor e mais clara resposta que eu já vi até agora. Excelente trabalho, Marc.
Jack Aidley
2
@JackAidley, obrigado pelo seu elogio! Quando vi a quantidade de atenção recebida por essa pergunta, imaginei que muitas pessoas poderiam estar interessadas em uma explicação um tanto extensa. Fico feliz que seja útil.
Marc Claesen
1
O que você não mencionou é que as estruturas de dados amigáveis ​​ao cache são projetadas para caber em uma linha de cache e alinhadas à memória para otimizar o uso das linhas de cache. Ótima resposta! impressionante.
22613 Matt
140

Além da resposta de @Marc Claesen, acho que um exemplo clássico instrutivo de código hostil ao cache é o código que varre uma matriz bidimensional C (por exemplo, uma imagem bitmap) em colunas, em vez de em linhas.

Os elementos adjacentes em uma linha também são adjacentes na memória, acessando-os em sequência significa acessá-los em ordem crescente de memória; isso é compatível com o cache, pois o cache tende a pré-buscar blocos contíguos de memória.

Em vez disso, o acesso a esses elementos em colunas não é amigável para o cache, pois os elementos na mesma coluna estão distantes na memória um do outro (em particular, a distância deles é igual ao tamanho da linha); portanto, quando você usa esse padrão de acesso, estão pulando na memória, potencialmente desperdiçando o esforço do cache de recuperar os elementos próximos na memória.

E tudo o que é preciso para estragar o desempenho é ir de

// Cache-friendly version - processes pixels which are adjacent in memory
for(unsigned int y=0; y<height; ++y)
{
    for(unsigned int x=0; x<width; ++x)
    {
        ... image[y][x] ...
    }
}

para

// Cache-unfriendly version - jumps around in memory for no good reason
for(unsigned int x=0; x<width; ++x)
{
    for(unsigned int y=0; y<height; ++y)
    {
        ... image[y][x] ...
    }
}

Esse efeito pode ser bastante dramático (várias ordens de magnitudes em velocidade) em sistemas com caches pequenos e / ou trabalhando com grandes matrizes (por exemplo, 10+ megapixels e imagens de 24 bpp nas máquinas atuais); por esse motivo, se você precisar fazer muitas verificações verticais, geralmente é melhor girar a imagem de 90 graus primeiro e executar as várias análises posteriormente, limitando o código hostil ao cache apenas à rotação.

Matteo Italia
fonte
Err, isso deveria ser x <width?
mowwwalker
13
Os editores de imagem modernos usam blocos como armazenamento interno, por exemplo, blocos de 64x64 pixels. Isso é muito mais compatível com o cache para operações locais (colocação de um dab, execução de um filtro de desfoque) porque os pixels vizinhos ficam próximos na memória nas duas direções, na maioria das vezes.
Max24
Tentei cronometrar um exemplo semelhante na minha máquina e descobri que os tempos eram os mesmos. Alguém mais tentou cronometrar isso?
gsingh2011
@ I3arnon: não, o primeiro é compatível com cache, uma vez que normalmente as matrizes C são armazenadas na ordem das linhas principais (é claro que se a sua imagem, por algum motivo, estiver armazenada na ordem das colunas principais, o inverso é verdadeiro).
Matteo Italia
1
@ Gauthier: sim, o primeiro trecho é o bom; Eu acho que quando escrevi isso, eu pensava da seguinte maneira: "Tudo o que é necessário [para arruinar o desempenho de um aplicativo em funcionamento] é ir de ... para ..."
Matteo Italia
88

A otimização do uso do cache se resume a dois fatores.

Localidade de referência

O primeiro fator (ao qual outros já se referiram) é a localidade de referência. A localidade de referência realmente tem duas dimensões: espaço e tempo.

  • Espacial

A dimensão espacial também se resume a duas coisas: primeiro, queremos compactar nossas informações densamente, para que mais informações caibam nessa memória limitada. Isso significa (por exemplo) que você precisa de uma grande melhoria na complexidade computacional para justificar estruturas de dados com base em pequenos nós unidos por ponteiros.

Segundo, queremos que as informações que serão processadas juntas também localizem juntas. Um cache típico funciona em "linhas", o que significa que quando você acessa algumas informações, outras informações em endereços próximos serão carregadas no cache com a parte que tocamos. Por exemplo, quando toco em um byte, o cache pode carregar 128 ou 256 bytes perto desse. Para tirar proveito disso, geralmente você deseja que os dados sejam organizados para maximizar a probabilidade de que você também use esses outros dados que foram carregados ao mesmo tempo.

Para apenas um exemplo realmente trivial, isso pode significar que uma pesquisa linear pode ser muito mais competitiva do que você esperaria com uma pesquisa binária. Depois de carregar um item de uma linha de cache, o uso dos demais dados nessa linha de cache é quase gratuito. Uma pesquisa binária se torna visivelmente mais rápida apenas quando os dados são grandes o suficiente para que a pesquisa binária reduz o número de linhas de cache que você acessa.

  • Tempo

A dimensão de tempo significa que, quando você executa algumas operações em alguns dados, deseja (o máximo possível) executar todas as operações nesses dados de uma só vez.

Desde que você tenha marcado isso como C ++, eu vou apontar para um exemplo clássico de um projeto relativamente cache-hostil: std::valarray. valarraysobrecargas operadores mais aritméticas, para que eu possa (por exemplo) dizer a = b + c + d;(onde a, b, ce dsão todos valarrays) para fazer disso elemento-wise dessas matrizes.

O problema é que ele percorre um par de entradas, coloca resultados temporariamente, percorre outro par de entradas e assim por diante. Com muitos dados, o resultado de um cálculo pode desaparecer do cache antes de ser usado no próximo cálculo, portanto, acabamos lendo (e gravando) os dados repetidamente antes de obtermos o resultado final. Se cada elemento do resultado final será algo como (a[n] + b[n]) * (c[n] + d[n]);, nós geralmente preferem ler cada a[n], b[n], c[n]e d[n]uma vez, fazer o cálculo, escrever o resultado, incremento ne repete até o estamos a fazer. 2

Compartilhamento de linha

O segundo fator principal é evitar o compartilhamento de linhas. Para entender isso, provavelmente precisamos fazer backup e examinar um pouco como os caches são organizados. A forma mais simples de cache é mapeada diretamente. Isso significa que um endereço na memória principal pode ser armazenado apenas em um ponto específico no cache. Se estivermos usando dois itens de dados que mapeiam para o mesmo local no cache, ele funcionará mal - cada vez que usamos um item de dados, o outro precisa ser liberado do cache para liberar espaço para o outro. O restante do cache pode estar vazio, mas esses itens não usarão outras partes do cache.

Para evitar isso, a maioria dos caches é chamada de "conjunto associativo". Por exemplo, em um cache associativo de conjunto de quatro vias, qualquer item da memória principal pode ser armazenado em qualquer um dos quatro locais diferentes no cache. Então, quando o cache está indo para carregar um item, ele procura o menos usado recentemente 3 item de entre os quatro, libera-lo para a memória principal, e carrega o novo item em seu lugar.

O problema é provavelmente bastante óbvio: para um cache mapeado diretamente, dois operandos que mapeiam para o mesmo local de cache podem levar a um mau comportamento. Um cache associativo de conjunto N-way aumenta o número de 2 para N + 1. Organizar um cache em mais "maneiras" requer um circuito extra e geralmente é mais lento, portanto (por exemplo) um cache associativo de 8192 vias também raramente é uma boa solução.

Por fim, esse fator é mais difícil de controlar no código portátil. Seu controle sobre onde seus dados são colocados geralmente é bastante limitado. Pior, o mapeamento exato do endereço para o cache varia entre processadores similares. Em alguns casos, no entanto, pode valer a pena fazer coisas como alocar um buffer grande e, em seguida, usar apenas partes do que você alocou para garantir o compartilhamento de dados com as mesmas linhas de cache (mesmo que você provavelmente precise detectar o processador exato e agir de acordo para fazer isso).

  • Compartilhamento falso

Há outro item relacionado chamado "compartilhamento falso". Isso ocorre em um sistema multiprocessador ou multicore, em que dois (ou mais) processadores / núcleos possuem dados separados, mas ficam na mesma linha de cache. Isso força os dois processadores / núcleos a coordenar seu acesso aos dados, mesmo que cada um tenha seu próprio item de dados separado. Especialmente se os dois modificarem os dados alternadamente, isso pode levar a uma desaceleração maciça, pois os dados precisam ser constantemente transferidos entre os processadores. Isso não pode ser facilmente curado, organizando o cache em mais "maneiras" ou qualquer coisa assim também. A principal maneira de evitá-lo é garantir que dois encadeamentos raramente (de preferência nunca) modifiquem dados que possam estar na mesma linha de cache (com as mesmas ressalvas sobre a dificuldade de controlar os endereços nos quais os dados são alocados).


  1. Quem conhece bem o C ++ pode se perguntar se isso está aberto à otimização por meio de modelos de expressão. Tenho certeza de que a resposta é que sim, isso poderia ser feito e, se fosse, provavelmente seria uma vitória substancial. No entanto, não estou ciente de que alguém o tenha feito e, dado o pouco que valarrayé usado, ficaria pelo menos um pouco surpreso ao ver alguém fazer isso também.

  2. Caso alguém se pergunte como valarray(projetado especificamente para desempenho) poderia estar tão errado, tudo se resume a uma coisa: ele foi realmente projetado para máquinas como os antigos Crays, que usavam memória principal rápida e sem cache. Para eles, esse era realmente um design quase ideal.

  3. Sim, estou simplificando: a maioria dos caches não mede exatamente o item menos usado recentemente com precisão, mas eles usam alguma heurística que se aproxima disso sem precisar manter um carimbo de data / hora completo para cada acesso.

Jerry Coffin
fonte
1
Gosto das informações extras na sua resposta, principalmente no valarrayexemplo.
Marc Claesen
1
+1 Finalmente: uma descrição simples da associatividade do conjunto! EDITAR MAIS: Esta é uma das respostas mais informativas sobre SO. Obrigado.
Engenheiro de
32

Bem-vindo ao mundo do Design Orientado a Dados. O mantra básico é Classificar, Eliminar ramificações, Lote, Eliminar virtualchamadas - todos os passos para uma melhor localidade.

Como você marcou a pergunta com C ++, aqui está a besteira típica obrigatória em C ++ . As armadilhas da programação orientada a objetos de Tony Albrecht também são uma ótima introdução ao assunto.

arul
fonte
1
o que você quer dizer com lote, pode não entender.
0x90 27/05
5
Lote: em vez de executar a unidade de trabalho em um único objeto, execute-a em um lote de objetos.
Arul 28/05
AKA bloqueando, bloqueando registros, bloqueando caches.
0x90 28/05
1
Bloqueio / Não bloqueio geralmente se refere a como os objetos se comportam em um ambiente simultâneo.
Arul 28/05
2
lote == vetorização
Amro
23

Apenas empilhamento: o exemplo clássico de código hostil ao cache versus amigável ao cache é o "bloqueio de cache" da multiplicação da matriz.

A multiplicação de matriz ingênua se parece com:

for(i=0;i<N;i++) {
   for(j=0;j<N;j++) {
      dest[i][j] = 0;
      for( k==;k<N;i++) {
         dest[i][j] += src1[i][k] * src2[k][j];
      }
   }
}

Se Nfor grande, por exemplo, se N * sizeof(elemType)for maior que o tamanho do cache, todos os acessos src2[k][j]serão um erro de cache.

Existem muitas maneiras diferentes de otimizar isso para um cache. Aqui está um exemplo muito simples: em vez de ler um item por linha de cache no loop interno, use todos os itens:

int itemsPerCacheLine = CacheLineSize / sizeof(elemType);

for(i=0;i<N;i++) {
   for(j=0;j<N;j += itemsPerCacheLine ) {
      for(jj=0;jj<itemsPerCacheLine; jj+) {
         dest[i][j+jj] = 0;
      }
      for( k=0;k<N;k++) {
         for(jj=0;jj<itemsPerCacheLine; jj+) {
            dest[i][j+jj] += src1[i][k] * src2[k][j+jj];
         }
      }
   }
}

Se o tamanho da linha de cache for 64 bytes e estivermos operando em flutuadores de 32 bits (4 bytes), haverá 16 itens por linha de cache. E o número de falhas de cache apenas com essa simples transformação é reduzido em aproximadamente 16 vezes.

As transformações mais sofisticadas operam em blocos 2D, otimizam para vários caches (L1, L2, TLB) e assim por diante.

Alguns resultados de "bloqueio de cache" no Google:

http://stumptown.cc.gt.atl.ga.us/cse6230-hpcta-fa11/slides/11a-matmul-goto.pdf

http://software.intel.com/en-us/articles/cache-blocking-techniques

Uma bela animação em vídeo de um algoritmo de bloqueio de cache otimizado.

http://www.youtube.com/watch?v=IFWgwGMMrh0

A disposição do loop está intimamente relacionada:

http://en.wikipedia.org/wiki/Loop_tiling

Krazy Glew
fonte
7
As pessoas que lêem isso também podem se interessar no meu artigo sobre multiplicação de matrizes, onde testei o algoritmo ikj "amigável ao cache" e o hostil algoritmo ijk multiplicando duas matrizes de 2000 x 2000.
Martin Thoma 29/05
3
k==;Espero que seja um erro de digitação?
TrebledJ
13

Atualmente, os processadores trabalham com muitos níveis de áreas de memória em cascata. Portanto, a CPU terá um monte de memória que está no próprio chip da CPU. Tem acesso muito rápido a essa memória. Existem diferentes níveis de cache, cada um com acesso mais lento (e maior) que o seguinte, até chegar à memória do sistema que não está na CPU e é relativamente mais lenta para acessar.

Logicamente, para o conjunto de instruções da CPU, você apenas se refere aos endereços de memória em um espaço de endereço virtual gigante. Quando você acessa um único endereço de memória, a CPU irá buscá-lo. antigamente, buscava apenas aquele endereço único. Hoje, porém, a CPU buscará um monte de memória em torno do bit solicitado e a copiará para o cache. Ele pressupõe que, se você solicitou um endereço específico, é altamente provável que você solicite um endereço próximo muito em breve. Por exemplo, se você estivesse copiando um buffer, leria e gravaria a partir de endereços consecutivos - um logo após o outro.

Então, hoje, quando você busca um endereço, ele verifica o primeiro nível de cache para ver se ele já leu esse endereço no cache, se não o encontrar, isso é uma falta de cache e precisa passar para o próximo nível de cache. cache para encontrá-lo, até que eventualmente precise sair para a memória principal.

O código compatível com o cache tenta manter os acessos próximos na memória, para minimizar perdas de cache.

Então, um exemplo seria imaginar que você deseja copiar uma tabela bidimensional gigante. Ele é organizado com a linha de alcance consecutivamente na memória e uma linha segue a seguinte logo depois.

Se você copiasse os elementos uma linha de cada vez da esquerda para a direita - isso seria compatível com o cache. Se você decidisse copiar a tabela uma coluna por vez, copiaria exatamente a mesma quantidade de memória - mas seria um cache hostil.

Rafael Baptista
fonte
4

É necessário esclarecer que não apenas os dados devem ser compatíveis com o cache, mas também são importantes para o código. Isso além da predição de ramificação, reordenação de instruções, evitando divisões reais e outras técnicas.

Normalmente, quanto mais denso o código, menos linhas de cache serão necessárias para armazená-lo. Isso resulta em mais linhas de cache disponíveis para dados.

O código não deve chamar funções em todo o lugar, pois normalmente exige uma ou mais linhas de cache próprias, resultando em menos linhas de cache para dados.

Uma função deve começar em um endereço compatível com o alinhamento de linha do cache. Embora haja opções do compilador (gcc) para isso, esteja ciente de que, se as funções forem muito curtas, pode ser um desperdício para cada um ocupar uma linha de cache inteira. Por exemplo, se três das funções usadas com mais freqüência caberem dentro de uma linha de cache de 64 bytes, isso será menos dispendioso do que se cada uma tiver sua própria linha e resultar em duas linhas de cache menos disponíveis para outro uso. Um valor de alinhamento típico pode ser 32 ou 16.

Portanto, dedique algum tempo extra para tornar o código denso. Teste diferentes construções, compile e revise o tamanho e o perfil do código gerado.

Olof Forshell
fonte
2

Como o @Marc Claesen mencionou, uma das maneiras de escrever código amigável ao cache é explorar a estrutura na qual nossos dados são armazenados. Além disso, outra maneira de escrever código amigável ao cache é: alterar a maneira como nossos dados são armazenados; em seguida, escreva um novo código para acessar os dados armazenados nessa nova estrutura.

Isso faz sentido no caso de como os sistemas de banco de dados linearizam as tuplas de uma tabela e as armazenam. Existem duas maneiras básicas de armazenar as tuplas de uma tabela, ou seja, armazenamento de linhas e armazenamento de colunas. No armazenamento de linhas, como o nome sugere, as tuplas são armazenadas em linhas. Vamos supor que uma tabela denominada Productsendo armazenada tenha 3 atributos, isto é , int32_t key, char name[56]e int32_t price, portanto, o tamanho total de uma tupla é 64bytes.

Podemos simular uma execução muito básica de consulta de armazenamento de linha na memória principal, criando uma matriz de Productestruturas com tamanho N, onde N é o número de linhas na tabela. Esse layout de memória também é chamado de matriz de estruturas. Portanto, a estrutura do Produto pode ser como:

struct Product
{
   int32_t key;
   char name[56];
   int32_t price'
}

/* create an array of structs */
Product* table = new Product[N];
/* now load this array of structs, from a file etc. */

Da mesma forma, podemos simular uma execução muito básica de consulta de armazenamento de colunas na memória principal, criando três matrizes de tamanho N, uma matriz para cada atributo da Producttabela. Esse layout de memória também é chamado de estrutura de matrizes. Portanto, as três matrizes para cada atributo do Produto podem ser como:

/* create separate arrays for each attribute */
int32_t* key = new int32_t[N];
char* name = new char[56*N];
int32_t* price = new int32_t[N];
/* now load these arrays, from a file etc. */

Agora, depois de carregar a matriz de estruturas (Layout da Linha) e as 3 matrizes separadas (Layout da Coluna), temos o armazenamento de linhas e o armazenamento de colunas em nossa tabela Productpresente em nossa memória.

Agora vamos para a parte do código amigável ao cache. Suponha que a carga de trabalho em nossa tabela seja tal que tenhamos uma consulta de agregação no atributo price. Tal como

SELECT SUM(price)
FROM PRODUCT

Para o armazenamento de linhas, podemos converter a consulta SQL acima em

int sum = 0;
for (int i=0; i<N; i++)
   sum = sum + table[i].price;

Para o armazenamento de colunas, podemos converter a consulta SQL acima em

int sum = 0;
for (int i=0; i<N; i++)
   sum = sum + price[i];

O código para o armazenamento de colunas seria mais rápido que o código para o layout da linha nesta consulta, pois requer apenas um subconjunto de atributos e, no layout da coluna, estamos fazendo exatamente isso, ou seja, acessando apenas a coluna de preço.

Suponha que o tamanho da linha do cache seja 64bytes.

No caso do layout da linha quando uma linha de cache é lida, o valor de preço de apenas 1 ( cacheline_size/product_struct_size = 64/64 = 1) tupla é lido, porque nosso tamanho de estrutura de 64 bytes e preenche toda a linha de cache, portanto, para cada tupla ocorre uma falta de cache no caso de de um layout de linha.

No caso do layout da coluna, quando uma linha de cache é lida, o valor do preço de 16 ( cacheline_size/price_int_size = 64/4 = 16) tuplas é lido, porque 16 valores de preço contíguos armazenados na memória são trazidos para o cache; portanto, para cada décima sexta tupla ocorre uma falta de cache no caso de layout da coluna.

Portanto, o layout da coluna será mais rápido no caso de uma consulta específica e mais rápido nessas consultas de agregação em um subconjunto de colunas da tabela. Você pode experimentar esse experimento usando os dados do benchmark TPC-H e comparar os tempos de execução dos dois layouts. O artigo da Wikipedia sobre sistemas de banco de dados orientados a colunas também é bom.

Portanto, nos sistemas de banco de dados, se a carga de trabalho da consulta for conhecida antecipadamente, podemos armazenar nossos dados em layouts que atenderão às consultas na carga de trabalho e acessar dados desses layouts. No caso do exemplo acima, criamos um layout de coluna e alteramos nosso código para calcular soma, para que ele se tornasse compatível com o cache.


fonte
1

Esteja ciente de que os caches não apenas armazenam em cache a memória contínua. Eles têm várias linhas (pelo menos 4), portanto, a memória descontínua e sobreposta pode ser frequentemente armazenada com a mesma eficiência.

O que está faltando em todos os exemplos acima são os benchmarks medidos. Existem muitos mitos sobre desempenho. A menos que você o avalie, você não sabe. Não complique seu código, a menos que você tenha uma melhoria medida .

Tuntable
fonte