Sobre caches em microcontroladores ARM

imagemOlá!



No artigo anterior , usamos um cache de processador para acelerar os gráficos em um microcontrolador no Embox . Nesse caso, usamos o modo "write-through". Em seguida, escrevemos sobre algumas das vantagens e desvantagens associadas ao modo "write-through", mas esta foi apenas uma visão geral superficial. Neste artigo, conforme prometido, quero dar uma olhada mais de perto nos tipos de caches em microcontroladores ARM, bem como compará-los. Claro, tudo isso será considerado do ponto de vista de um programador, e não estamos planejando entrar em detalhes sobre o controlador de memória neste artigo.



Começarei onde parei no artigo anterior, a saber, a diferença entre os modos "write-back" e "write-through", uma vez que esses dois modos são usados ​​com mais frequência. Em resumo:



  • "Escreva de volta". Os dados de gravação vão apenas para o cache. A gravação real na memória é adiada até que o cache fique cheio e seja necessário espaço para novos dados.
  • "Escrita". A gravação ocorre “simultaneamente” no cache e na memória.


Write-through



As vantagens do write-through são consideradas facilidade de uso, o que potencialmente reduz erros. De fato, neste modo a memória está sempre no estado correto e não requer procedimentos de atualização adicionais.



Claro, parece que isso deve ter um grande impacto no desempenho, mas o próprio STM neste documento diz que não é:

Write-through: triggers a write to the memory as soon as the contents on the cache line are written to. This is safer for the data coherency, but it requires more bus accesses. In practice, the write to the memory is done in the background and has a little effect unless the same cache set is being accessed repeatedly and very quickly. It is always a tradeoff.
Ou seja, inicialmente assumimos que, como a gravação é para a memória, o desempenho nas operações de gravação será praticamente o mesmo que sem um cache, e o ganho principal ocorre devido a leituras repetidas. No entanto, o STM refuta isso, ele diz que os dados na memória ficam "em segundo plano", de modo que o desempenho de gravação é quase o mesmo que no modo "write-back". Isso, em particular, pode depender dos buffers internos do controlador de memória (FMC).



Desvantagens do modo "write-through":



  • O acesso sequencial e rápido à mesma memória pode prejudicar o desempenho. No modo "write-back", os acessos frequentes sequenciais à mesma memória serão, pelo contrário, uma vantagem.
  • Como no caso de "write-back", você ainda precisa fazer uma invalidação de cache após o final das operações de DMA.
  • Bug “Corrupção de dados em uma sequência de armazenamentos e carregamentos Write-Through” em algumas versões do Cortex-M7. Foi apontado para nós por um dos desenvolvedores do LVGL.


Escreva de volta



Como mencionado acima, neste modo (ao contrário de "gravação"), os dados geralmente não entram na memória ao gravar, mas apenas no cache. Como write-through, esta estratégia tem duas subopções - 1) write alocate, 2) nenhum write alocate. Falaremos mais sobre essas opções.



Gravar alocar



Como regra, "read alocar" é sempre usado em caches - ou seja, em uma falha de cache para leitura, os dados são buscados na memória e colocados no cache. Da mesma forma, uma falha de gravação pode fazer com que os dados sejam carregados no cache ("alocação de gravação") ou não carregados ("nenhuma alocação de gravação").



Normalmente, na prática, as combinações "write-back write allocate" ou "write-through no write allocate" são utilizadas. Mais adiante nos testes, tentaremos verificar um pouco mais detalhadamente em quais situações usar "alocação de gravação" e em quais situações "não alocação de gravação".



MPU



Antes de passar para a parte prática, precisamos descobrir como definir os parâmetros da região de memória. Para selecionar o modo de cache (ou desabilitá-lo) para uma região específica da memória na arquitetura ARMv7-M, MPU (Unidade de Proteção de Memória) é usado.



O controlador MPU suporta a configuração de regiões de memória. Especificamente na arquitetura ARMV7-M, pode haver até 16 regiões. Para essas regiões, você pode definir de forma independente: endereço inicial, tamanho, direitos de acesso (leitura / gravação / execução, etc.), atributos - TEX, armazenável em cache, em buffer, compartilhável, bem como outros parâmetros. Usando esse mecanismo, em particular, você pode conseguir qualquer tipo de cache para uma região específica. Por exemplo, podemos nos livrar da necessidade de chamar cache_clean / cache_invalidate simplesmente alocando uma região de memória para todas as operações DMA e marcando essa memória como não armazenável em cache.



Um ponto importante a ser observado ao trabalhar com MPU:

O endereço básico, o tamanho e os atributos de uma região são todos configuráveis, com a regra geral de que todas as regiões são alinhadas naturalmente. Isso pode ser declarado como:

RegionBaseAddress [(N-1): 0] = 0, onde N é log2 (SizeofRegion_in_bytes)
Em outras palavras, o endereço inicial da região de memória deve ser alinhado ao seu próprio tamanho. Se você tiver, por exemplo, uma região de 16 KB, será necessário alinhá-la em 16 KB. Se a região da memória for 64 KB, alinhe para 64 KB. E assim por diante. Se isso não for feito, o MPU pode “cortar” automaticamente a região para o tamanho correspondente ao seu endereço inicial (testado na prática).



A propósito, existem vários bugs no STM32Cube. Por exemplo:



  MPU_InitStruct.BaseAddress = 0x20010000;
  MPU_InitStruct.Size = MPU_REGION_SIZE_256KB;


Você pode ver que o endereço inicial está alinhado com 64 KB. E queremos que o tamanho da região seja de 256 KB. Neste caso, você terá que criar 3 regiões: a primeira de 64 Kb, a segunda de 128 Kb e a terceira de 64 Kb.



Você só precisa especificar regiões com propriedades diferentes das padrão. O fato é que os atributos de todas as memórias quando o cache do processador é habilitado são descritos na arquitetura ARM. Há um conjunto padrão de propriedades (por exemplo, é por isso que a SRAM STM32F7 tem um modo "write-back write-alocate" por padrão). Portanto, se você precisar de um modo não padrão para algumas das memórias, você precisará definir suas propriedades via MPU. Neste caso, dentro da região, pode-se definir uma sub-região com propriedades próprias, destacando nesta região outra de alta prioridade com as propriedades requeridas.



TCM



Conforme segue da documentação (seção 2.3 SRAM incorporada), os primeiros 64 KB de SRAM em STM32F7 não podem ser armazenados em cache. Na própria arquitetura ARMv7-M, SRAM está localizado em 0x20000000. TCM também se refere a SRAM, mas está em um barramento diferente em relação ao resto das memórias (SRAM1 e SRAM2) e está localizado “mais próximo” do processador. Por conta disso, essa memória é muito rápida, na verdade, tem a mesma velocidade do cache. E, por causa disso, o armazenamento em cache não é necessário e esta região não pode ser armazenada em cache. Na verdade, TCM é outro tipo de cache.



Cache de instrução



Deve-se notar que tudo o que foi discutido acima se refere ao cache de dados (D-Cache). Mas, além do cache de dados, o ARMv7-M também fornece um cache de instruções - cache de instruções (I-Cache). O I-Cache permite que você transfira algumas das instruções executáveis ​​(e subsequentes) para o cache, o que pode acelerar significativamente o programa. Especialmente nos casos em que o código está em uma memória mais lenta do que FLASH, por exemplo, QSPI.



Para reduzir a imprevisibilidade nos testes com o cache abaixo, desabilitaremos intencionalmente o I-Cache e pensaremos exclusivamente nos dados.



Ao mesmo tempo, quero observar que ativar o I-Cache é bastante simples e não requer nenhuma ação adicional do MPU, ao contrário do D-Cache.



Testes sintéticos



Depois de discutir a parte teórica, vamos passar aos testes para entender melhor a diferença e o escopo de aplicabilidade de um determinado modelo. Como eu disse acima, desative o I-Cache e funcione apenas com o D-Cache. Eu também compilo intencionalmente com -O0 para que os loops nos testes não sejam otimizados. Vamos testar através de memória SDRAM externa. Com a ajuda do MPU, marquei a região de 64 KB, e vamos expor os atributos que precisamos para essa região.



Como os testes com caches são muito caprichosos e influenciados por tudo e todos no sistema - vamos tornar o código linear e contínuo. Para fazer isso, desative as interrupções. Além disso, não mediremos o tempo com temporizadores, mas sim com DWT (Data Watchpoint and Trace unit), que possui um contador de ciclos do processador de 32 bits. Com base nisso (na Internet), as pessoas causam atrasos de microssegundos nos drivers. O contador transborda rapidamente na frequência do sistema de 216 MHz, mas você pode medir até 20 segundos. Vamos apenas lembrar disso e fazer testes neste intervalo de tempo, zerando previamente o contador do relógio antes de iniciar.



Você pode ver os códigos de teste completos aqui . Todos os testes foram realizados na placa 32F769IDISCOVERY .



Memória não armazenável em cache VS. Escreva de volta



Então, vamos começar com alguns testes muito simples.



Apenas escrevemos sequencialmente na memória.



    dst = (uint8_t *) DATA_ADDR;

    for (i = 0; i < ITERS * 8; i++) {
        for (j = 0; j < DATA_LEN; j++) {
            *dst = VALUE;
            dst++;
        }
        dst -= DATA_LEN;
    }


Também gravamos sequencialmente na memória, mas não um byte de cada vez, mas expandimos um pouco os loops.



    for (i = 0; i < ITERS * BLOCKS * 8; i++) {
        for (j = 0; j < BLOCK_LEN; j++) {
            *dst = VALUE;
            *dst = VALUE;
            *dst = VALUE;
            *dst = VALUE;
            dst++;
        }
        dst -= BLOCK_LEN;
    }


Também escrevemos sequencialmente na memória, mas agora também adicionaremos leitura.



    for (i = 0; i < ITERS * BLOCKS * 8; i++) {
        dst = (uint8_t *) DATA_ADDR;

        for (j = 0; j < BLOCK_LEN; j++) {
            val = VALUE;
            *dst = val;
            val = *dst;
            dst++;
        }
    }


Se você executar todos esses três testes, eles darão exatamente o mesmo resultado, não importa o modo que você escolher:



mode: nc, iters=100, data len=65536, addr=0x60100000
Test1 (Sequential write):
  0s 728ms
Test2 (Sequential write with 4 writes per one iteration):
  7s 43ms
Test3 (Sequential read/write):
  1s 216ms


E isso é razoável, SDRAM não é tão lento, especialmente quando você considera os buffers internos do FMC por meio do qual está conectado. Mesmo assim, esperava uma ligeira variação nos números, mas descobri que não era nesses testes. Bem, vamos pensar mais.



Vamos tentar "estragar" a vida do SDRAM misturando leituras e gravações. Para fazer isso, vamos expandir os ciclos, adicionar algo tão comum na prática como o incremento de um elemento de array:



    for (i = 0; i < ITERS * BLOCKS; i++) {
        for (j = 0; j < BLOCK_LEN; j++) {
            // 16 lines
            arr[i]++;
            arr[i]++;
	***
            arr[i]++;
        }
    }


Resultado:



  :   4s 743ms
Write-back:                     :   4s 187ms


Já melhor - com o cache acabou sendo meio segundo mais rápido. Vamos tentar complicar ainda mais o teste adicionando acesso por índices “esparsos”. Por exemplo, com um índice:



    for (i = 0; i < ITERS * BLOCKS; i++) {
        for (j = 0; j < BLOCK_LEN; j++) {
            arr[i + 0 ]++;
            ***
            arr[i + 3 ]++;
            arr[i + 4 ]++;
            arr[i + 100]++;
            arr[i + 6 ]++;
            arr[i + 7 ]++;
            ***
            arr[i + 15]++;
        }
    }


Resultado:



  :   11s 371ms
Write-back:                     :   4s 551ms


Agora a diferença com o cache se tornou mais do que perceptível! E ainda por cima, apresentamos um segundo índice desse tipo:



    for (i = 0; i < ITERS * BLOCKS; i++) {
        for (j = 0; j < BLOCK_LEN; j++) {
            arr[i + 0 ]++;
            ***
            arr[i + 4 ]++;
            arr[i + 100]++;
            arr[i + 6 ]++;
            ***
            arr[i + 9 ]++;
            arr[i + 200]++;
            arr[i + 11]++;
            arr[i + 12]++;
            ***
            arr[i + 15]++;
        }
    }


Resultado:



  :   12s 62ms
Write-back:                     :   4s 551ms


Vemos como o tempo para a memória não armazenada em cache aumentou quase um segundo, enquanto para o cache permanece o mesmo.



Grave a alocação VS. nenhuma gravação alocada



Agora vamos lidar com o modo "write alocar". É ainda mais difícil ver a diferença aqui, uma vez que se na situação entre memória não cacheada e “write-back” eles se tornam claramente visíveis já a partir do 4º teste, as diferenças entre “write alocate” e “no write alocate” ainda não foram reveladas pelos testes. Vamos pensar - quando a “alocação de gravação” será mais rápida? Por exemplo, quando você tem muitas gravações em locais de memória sequencial e há poucas leituras nesses locais de memória. Nesse caso, no modo “sem alocação de gravação”, receberemos erros constantes e os elementos errados serão carregados no cache durante a leitura. Vamos simular esta situação:



    for (i = 0; i < ITERS * BLOCKS; i++) {
        for (j = 0; j < BLOCK_LEN; j++) {
            arr[j + 0 ]  = VALUE;
            ***
            arr[j + 7 ]  = VALUE;
            arr[j + 8 ]  = arr[i % 1024 + (j % 256) * 128];
            arr[j + 9 ]  = VALUE;
            ***
            arr[j + 15 ]  = VALUE;
        }
    }


Aqui, 15 de 16 registros são definidos para a constante VALUE, enquanto a leitura é realizada a partir de elementos diferentes (e não relacionados à gravação) arr [i% 1024 + (j% 256) * 128]. Acontece que, com a estratégia de alocação sem gravação, apenas esses elementos serão carregados no cache. A razão pela qual essa indexação é usada (i% 1024 + (j% 256) * 128) é a “degradação da velocidade” do FMC / SDRAM. Visto que os acessos à memória em endereços significativamente diferentes (não sequenciais), podem afetar significativamente a velocidade do trabalho.



Resultado:



Write-back                                           :   4s 720ms
Write-back no write allocate:               :   4s 888ms


Por fim, conseguimos uma diferença, embora não tão perceptível, mas já visível. Ou seja, nossa hipótese foi confirmada.



E, finalmente, o caso mais difícil, na minha opinião. Queremos entender quando “sem alocação de gravação” é melhor do que “alocação de gravação”. A primeira é melhor se “frequentemente” nos referirmos a endereços com os quais não trabalharemos no futuro próximo. Esses dados não precisam ser armazenados em cache.



No próximo teste, no caso de “alocação de escrita”, os dados serão preenchidos na leitura e escrita. Eu fiz um array de 64 KB “arr2”, então o cache será esvaziado para trocar novos dados. No caso de “nenhuma alocação de gravação”, criei um array “arr” de 4096 bytes e apenas ele entrará no cache, o que significa que os dados do cache não serão liberados na memória. Devido a isso, tentaremos obter pelo menos uma pequena vitória.



    arr = (uint8_t *) DATA_ADDR;
    arr2 = arr;

    for (i = 0; i < ITERS * BLOCKS; i++) {
        for (j = 0; j < BLOCK_LEN; j++) {
            arr2[i * BLOCK_LEN            ] = arr[j + 0 ];
            arr2[i * BLOCK_LEN + j*32 + 1 ] = arr[j + 1 ];
            arr2[i * BLOCK_LEN + j*64 + 2 ] = arr[j + 2 ];
            arr2[i * BLOCK_LEN + j*128 + 3] = arr[j + 3 ];
            arr2[i * BLOCK_LEN + j*32 + 4 ] = arr[j + 4 ];
            ***
            arr2[i * BLOCK_LEN + j*32 + 15] = arr[j + 15 ];
        }
    }


Resultado:



Write-back                                           :   7s 601ms
Write-back no write allocate:               :   7s 599ms


Pode-se ver que o modo "write-back" "write allocate" é ligeiramente mais rápido. Mas o principal é que é mais rápido.



Não consegui uma demonstração melhor, mas tenho certeza de que existem situações práticas onde a diferença é mais tangível. Os leitores podem sugerir suas próprias opções!



Exemplos práticos



Vamos passar dos exemplos sintéticos aos reais.



ping



Um dos mais simples é o ping. É fácil de iniciar e a hora pode ser visualizada diretamente no host. Embox foi construído com a otimização -O2. Darei imediatamente os resultados:



    :  ~0.246 c
Write-back                        :  ~0.140 c


Opencv



Outro exemplo de um problema real no qual queríamos experimentar o subsistema de cache é o OpenCV em STM32F7 . Nesse artigo, foi mostrado que era perfeitamente possível lançar, mas o desempenho era bastante baixo. Para demonstração, usaremos um exemplo padrão que extrai bordas com base no filtro Canny. Vamos medir o tempo de execução com e sem caches (D-cache e I-cache).



   gettimeofday(&tv_start, NULL);

    cedge.create(image.size(), image.type());
    cvtColor(image, gray, COLOR_BGR2GRAY);

    blur(gray, edge, Size(3,3));
    Canny(edge, edge, edgeThresh, edgeThresh*3, 3);
    cedge = Scalar::all(0);

    image.copyTo(cedge, edge);

    gettimeofday(&tv_cur, NULL);
    timersub(&tv_cur, &tv_start, &tv_cur);


Sem cache:



> edges fruits.png 20 
Processing time 0s 926ms
Framebuffer: 800x480 32bpp
Image: 512x269; Threshold=20


Com cache:



> edges fruits.png 20 
Processing time 0s 134ms
Framebuffer: 800x480 32bpp
Image: 512x269; Threshold=20


Ou seja, a aceleração de 926ms e 134ms é quase 7 vezes.



Na verdade, somos frequentemente questionados sobre o OpenCV no STM32, em particular, qual é o desempenho. Acontece que o FPS certamente não é alto, mas 5 quadros por segundo é bastante realista de se obter.



Não em cache ou memória em cache, mas com invalidação de cache?



Em dispositivos reais, o DMA é amplamente utilizado, claro, dificuldades estão associadas a ele, pois é necessário sincronizar a memória mesmo para o modo “write-through”. Há um desejo natural de simplesmente alocar uma parte da memória que não será armazenada em cache e usá-la ao trabalhar com DMA. Um pouco distraído. No Linux, isso é feito por uma função via dma_coherent_alloc () . E sim, este é um método muito eficaz, por exemplo, ao trabalhar com pacotes de rede no sistema operacional, os dados do usuário passam por um grande estágio de processamento antes de chegar ao driver, e no driver os dados preparados com todos os cabeçalhos são copiados para buffers que usam memória não armazenada em cache.



Existem casos em que limpar / invalidar é preferível em um driver com DMA? Sim existe. Por exemplo, memória de vídeo, o que nos levou adê uma olhada mais de perto em como o cache () funciona. No modo de buffer duplo, o sistema possui dois buffers, nos quais ele se conecta e os entrega ao controlador de vídeo. Se você tornar essa memória não armazenável em cache, haverá uma queda no desempenho. Portanto, é melhor fazer uma limpeza antes de enviar o buffer para o controlador de vídeo.



Conclusão



Nós descobrimos um pouco sobre os diferentes tipos de caches no ARMv7m: write-back, write-through, bem como as configurações de “write alocar” e “no write alocate”. Construímos testes sintéticos nos quais tentamos descobrir quando um modo é melhor do que o outro e também consideramos exemplos práticos com ping e OpenCV. Na Embox, estamos apenas trabalhando neste tópico, então o subsistema correspondente ainda está sendo elaborado. As vantagens de usar caches são definitivamente perceptíveis.



Todos os exemplos podem ser vistos e reproduzidos construindo Embox a partir do repositório aberto.



PS



Se você estiver interessado no tópico de programação de sistema e OSDev, a conferência OS Day será realizada amanhã ! Este ano está online, por isso não perca quem quiser! Embox vai se apresentar amanhã às 12h00



All Articles